Pocket Studio Academy
HomePart 33.9

Text fields and user input

Full course11 min read·3 questions

Why a Compose text field holds no text of its own, the two-parameter pattern that makes typing work, and how to add labels, validation, number keyboards and input filtering without fighting the framework.

The field that will not type

Add a text field. Run the app. Tap it, the keyboard slides up, you type your name — and the field stays completely empty.

kotlin
1OutlinedTextField(
2    value = "",
3    onValueChange = { }
4)

Nothing is broken. That field is doing precisely what you told it to: always show an empty string. Your keystrokes arrive, they are handed to onValueChange, and onValueChange throws them away.

This trips up nearly everyone, because every text box you have ever met in any other toolkit remembers what you typed. A Compose text field does not. Once you see why, the whole design clicks — and it is the same idea you will meet again as in Part 4.

Think of it like this

Think of the price display on an old shop till.

The display is a piece of glass with digits on it. It does not remember a price. It has no opinion about prices. It shows whatever number the machine inside is currently holding — that is the whole of its job.

The keys are a separate thing. Pressing 7 does not change the display. It sends a message to the machine: the customer pressed 7. The machine updates its number, and because the display always shows the machine's number, the 7 appears.

If nobody ever wired the keys to the machine, you could hammer the keypad all day and the glass would sit there showing 0.00, working perfectly.

value is the glass. onValueChange is the wire.

The pattern, in three lines

kotlin
1var name by remember { mutableStateOf("") }
2
3OutlinedTextField(
4    value = name,
5    onValueChange = { name = it }
6)
  • value is what the field displays. It is read from your , every recomposition.
  • onValueChange is called on every keystroke, and it is the whole new text — not the single character that was typed. Deleting a letter calls it too, with the shorter string.

The moment you write name = it, the loop closes: keystroke → state → recomposition → field shows the new value. It happens in well under a frame, so it feels exactly like a normal text box.

Why go to the trouble? Because now there is only one copy of the text, and it is yours. You can read it, clear it, pre-fill it from a database, or copy it into a second field, with no special API and nothing to keep in sync. That is the same promise Lesson 3.1 made about the whole of Compose, applied to the one component where people expect an exception.

Tip

Because onValueChange sees the new text before you store it, it is also the place to filter. This field simply refuses anything that is not a digit:

kotlin
1onValueChange = { new ->
2    if (new.all { it.isDigit() }) {
3        target = new
4    }
5}

No state change means no recomposition, which means the rejected character never appears. The user sees nothing happen, which is exactly right.

A real form

PlayerNames.ktkotlin
1@Composable
2fun PlayerNames() {
3    var p1 by rememberSaveable { mutableStateOf("") }
4    var target by rememberSaveable {
5        mutableStateOf("30")
6    }
7    val tooShort =
8        p1.isNotEmpty() && p1.trim().length < 2
9
10    Column(
11        modifier = Modifier
12            .fillMaxWidth()
13            .padding(20.dp),
14        verticalArrangement =
15            Arrangement.spacedBy(14.dp)
16    ) {
17        OutlinedTextField(
18            value = p1,
19            onValueChange = { p1 = it },
20            label = { Text(text = "Player 1 name") },
21            placeholder = { Text(text = "Ada") },
22            singleLine = true,
23            isError = tooShort,
24            supportingText = {
25                if (tooShort) {
26                    Text(text = "At least 2 letters")
27                }
28            },
29            modifier = Modifier.fillMaxWidth()
30        )
31        OutlinedTextField(
32            value = target,
33            onValueChange = { new ->
34                if (new.all { it.isDigit() }) {
35                    target = new
36                }
37            },
38            label = { Text(text = "Target score") },
39            singleLine = true,
40            keyboardOptions = KeyboardOptions(
41                keyboardType = KeyboardType.Number,
42                imeAction = ImeAction.Done
43            ),
44            modifier = Modifier.fillMaxWidth()
45        )
46    }
47}
9:41▲ ▮
Player 1 name
A
At least 2 letters
Target score
30

PlayerNames() with a one-letter name typed. Label floated, error showing.

Choosing the right keyboard

Nothing says "amateur app" like a full QWERTY keyboard for a field that only accepts numbers.

kotlin
1keyboardOptions = KeyboardOptions(
2    keyboardType = KeyboardType.Number,
3    imeAction = ImeAction.Done
4)
keyboardTypeGives you
TextThe normal keyboard (default)
NumberA number pad
DecimalNumbers with a decimal point
Email@ and .com on the main row
PasswordNormal keys, no autocorrect

imeAction changes the bottom-right key on the keyboard: Done, Next, Search, Send. Set it to Next on every field but the last one in a form and people can tab straight through.

Careful

keyboardType = KeyboardType.Number changes which keyboard appears. It does not stop text arriving — a paste, a physical keyboard, or a slightly unusual soft keyboard can all deliver letters anyway. If your code is going to call .toInt() on the result, filter in onValueChange as well, exactly as target does above. Trust the keyboard for convenience, never for correctness.

TextField or OutlinedTextField?

Both take identical parameters.

  • OutlinedTextField draws a box around itself. Better on busy or coloured backgrounds, and easier to see as a target. Use it by default.
  • TextField is filled with a tinted background and underlined. Slightly more compact; good for dense forms and search bars.

Pick one and use it everywhere in an app. Mixing them looks like an accident.

The keyboard covering the field

The moment you have a text field near the bottom of a screen, the keyboard will slide up over it. Compose has a modifier for that:

kotlin
1Column(
2    modifier = Modifier
3        .fillMaxSize()
4        .imePadding()
5        .padding(20.dp)
6) {
7    // fields
8}

Modifier.imePadding() adds bottom padding equal to the height of the on-screen keyboard, so your content slides up out of the way. Pocket Notes uses it on the editor screen, and Lesson 5.9 puts it in properly.

Try it in Pocket Studio
  1. Open ComposeLab and put this in setContent, on its own: var name by remember { mutableStateOf("") } and then an OutlinedTextField with value = name and onValueChange = { } — an empty handler on purpose.
  2. Tap Run, tap the field, and type. Nothing appears. Look at the empty braces and say out loud what they do with the text.
  3. Change the handler to onValueChange = { name = it }. Run again. Now it types.
  4. Add label = { Text("Player 1 name") } and Run. Notice the label floats up as soon as you start typing.
  5. Add singleLine = true and keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number). Run and tap the field — a number pad. Imports: androidx.compose.foundation.text.KeyboardOptions and androidx.compose.ui.text.input.KeyboardType.
  6. Now type the full PlayerNames composable from this lesson and Run it. Type one letter into the name field and watch the red error appear, then a second letter and watch it go.
  7. Try typing a letter into the target field. Nothing happens, because the filter rejected it before it ever reached state.
Error Doctor5 common errors
I can type but nothing appears in the field
MeansonValueChange is not storing the new text. Either it is empty, or it updates something the value parameter does not read.
FixMake sure the handler assigns to the same state the value reads: value = name, onValueChange = { name = it }. Check for a typo where one field's handler updates the other field's state — a classic in two-field forms.
e: Unresolved reference: OutlinedTextField
MeansMissing import. It comes from Material 3, alongside Text and Button.
FixAdd import androidx.compose.material3.OutlinedTextField. If Pocket Studio offers you androidx.compose.material.OutlinedTextField — with no 3 — do not take it; that is Material 2 and it will look wrong next to everything else.
e: Type mismatch: inferred type is String but TextFieldValue was expected
MeansThere are two versions of every text field. The TextFieldValue one carries the text and the cursor position and selection, and you have imported or selected that one.
FixUse the plain String version unless you specifically need to control the cursor. Check your value is a String and remove any TextFieldValue import. Pocket Notes uses the String version throughout.
e: Unresolved reference: KeyboardOptions
MeansKeyboard settings live in the foundation text package, not in Material 3, and KeyboardType is in a third package again.
FixAdd import androidx.compose.foundation.text.KeyboardOptions and import androidx.compose.ui.text.input.KeyboardType. For ImeAction you also need androidx.compose.ui.text.input.ImeAction.
The keyboard slides up and hides the field I am typing in
MeansBy default the content does not move out of the keyboard's way, so a field near the bottom of the screen ends up behind it.
FixAdd Modifier.imePadding() to the scrolling container, before your own padding. Import androidx.compose.foundation.layout.imePadding. If the whole screen still refuses to move, check the Activity in the manifest is not set to adjustNothing.
Recap
  • A text field holds no text. value is what it displays; onValueChange is how it reports a change. Wire both to the same or it will appear frozen.
  • it in onValueChange is the whole new string, not one character.
  • Filter unwanted input inside onValueChange by simply not storing it.
  • label floats and stays; placeholder is a hint that disappears. Prefer label.
  • isError plus supportingText is the standard validation pair, both driven by one calculated value.
  • keyboardOptions picks the keyboard and the action key — a convenience, never a guarantee.
  • Modifier.imePadding() keeps the keyboard from covering the field.
  • Next: LazyColumn — showing a list of a thousand things without building a thousand rows.