Text fields and user input
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.
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 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
1var name by remember { mutableStateOf("") }
2
3OutlinedTextField(
4 value = name,
5 onValueChange = { name = it }
6)valueis what the field displays. It is read from your , every recomposition.onValueChangeis called on every keystroke, anditis 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.
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:
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
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}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.
1keyboardOptions = KeyboardOptions(
2 keyboardType = KeyboardType.Number,
3 imeAction = ImeAction.Done
4)keyboardType | Gives you |
|---|---|
Text | The normal keyboard (default) |
Number | A number pad |
Decimal | Numbers with a decimal point |
Email | @ and .com on the main row |
Password | Normal 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.
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.
OutlinedTextFielddraws a box around itself. Better on busy or coloured backgrounds, and easier to see as a target. Use it by default.TextFieldis 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:
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.
- Open ComposeLab and put this in
setContent, on its own:var name by remember { mutableStateOf("") }and then anOutlinedTextFieldwithvalue = nameandonValueChange = { }— an empty handler on purpose. - Tap Run, tap the field, and type. Nothing appears. Look at the empty braces and say out loud what they do with the text.
- Change the handler to
onValueChange = { name = it }. Run again. Now it types. - Add
label = { Text("Player 1 name") }and Run. Notice the label floats up as soon as you start typing. - Add
singleLine = trueandkeyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number). Run and tap the field — a number pad. Imports:androidx.compose.foundation.text.KeyboardOptionsandandroidx.compose.ui.text.input.KeyboardType. - Now type the full
PlayerNamescomposable 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. - Try typing a letter into the target field. Nothing happens, because the filter rejected it before it ever reached state.
onValueChange is not storing the new text. Either it is empty, or it updates something the value parameter does not read.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.Text and Button.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.TextFieldValue one carries the text and the cursor position and selection, and you have imported or selected that one.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.KeyboardType is in a third package again.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.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.- A text field holds no text.
valueis what it displays;onValueChangeis how it reports a change. Wire both to the same or it will appear frozen. itinonValueChangeis the whole new string, not one character.- Filter unwanted input inside
onValueChangeby simply not storing it. labelfloats and stays;placeholderis a hint that disappears. Preferlabel.isErrorplussupportingTextis the standard validation pair, both driven by one calculated value.keyboardOptionspicks 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.