Checkout is the screen where an ordering app makes or loses its money, and on a phone it is almost entirely typing. Every field the customer fights with — the keyboard with no @ key, the Next button that does nothing, the Pay button hidden behind the keyboard — is a reason to give up.
This lesson is the input side of Compose: text fields, the keyboard as part of the UI, focus, autofill, and a validation model that keeps the rules out of the composable.
A text field owns nothing
A Compose TextField does not remember what you typed. It takes value and
reports onValueChange, and if you do not feed the new value back in, the field simply does
not change. It is state hoisting applied to input, and it is what lets the form's state live wherever
it should — which in this app is three different places:
- Checkout keeps its form in the ViewModel's
SavedStateHandle, because the ViewModel validates and submits it and it must survive process death. - The address sheet keeps its fields in
rememberSaveableinside the sheet, because nothing outside the sheet needs them until Save. - Password fields use plain
remember, because saved state is written to disk.
Every one of them renders through the same component.
One field component
fun LabeledTextField(
value: String,
onValueChange: (String) -> Unit,
label: String,
modifier: Modifier = Modifier,
error: String? = null,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
visualTransformation: VisualTransformation = VisualTransformation.None,
enabled: Boolean = true,
autofill: ContentType? = null,
) {
It wraps Material's OutlinedTextField with the app's colours and three behaviours
every field needs. The label floats above the text once there is some. The error, when present,
turns the outline red and appears underneath as supportingText. And
singleLine is on, because a single-line field's Enter key is an IME action rather than a
newline — which matters in a moment.
The error is also written into the field's semantics, so TalkBack reads it when the field is focused:
modifier = modifier
.fillMaxWidth()
.semantics {
if (error != null) error(error)
// Tells the autofill service (Google, a password manager) what belongs here, so a
// saved email, password or address can be offered with one tap.
if (autofill != null) contentType = autofill
},
Red text alone tells a blind customer nothing. The accessibility lesson returns to this.
The keyboard is part of the UI
KeyboardOptions tells the system which keyboard to show and what its action key does.
Getting it right per field is the cheapest usability improvement there is:
value = form.email,
onValueChange = { v -> viewModel.updateForm { it.copy(email = v) } },
label = "Email (for your receipt)",
autofill = ContentType.EmailAddress,
error = error(Field.EMAIL),
enabled = !locked,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next),
keyboardActions = next,
KeyboardType.Emailputs @ and .com on the first page and turns off autocorrect, which otherwise "fixes" addresses into words.KeyboardType.Numberon the ZIP,Phoneon the phone number,Passwordon passwords (which also disables learning and suggestions).KeyboardCapitalization.Wordson names and cities,Characterson the two-letter state. The default for a plain field is no capitalisation, which leaves every customer's name in lowercase.ImeAction.Nextturns the return key into a forward arrow;Doneinto a check mark.
Moving focus
An IME action only labels the key. What happens when it is pressed is
KeyboardActions, and the useful action for Next is "move focus to the next
field":
val focus = LocalFocusManager.current
val next = KeyboardActions(onNext = { focus.moveFocus(FocusDirection.Down) })
moveFocus(FocusDirection.Down) moves to the next focusable element below, in layout
order, so a whole form can be filled without touching the screen between fields. On the last field,
Done clears focus — which closes the keyboard — and on the sign-in sheet it submits:
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { viewModel.signIn(email, password, onDismiss) }),
For a layout where "down" is ambiguous, FocusRequesters let you name exactly which
field comes next; for a simple column, the focus manager is enough.
Autofill
The fastest field is the one the customer never types. Android's autofill framework — Google's own, or a password manager — can fill an email, a password or a whole address, but only if it knows what each field is. A label saying "Email" means nothing to it; a semantics content type does:
label = "Email",
error = state.fieldErrors["email"],
autofill = ContentType.EmailAddress + ContentType.Username,
label = "Password (8+ characters)",
error = state.fieldErrors["password"],
// NewPassword, not Password: a password manager offers to GENERATE one here.
autofill = ContentType.NewPassword,
Note the difference between the two password types. Password on sign-in offers a saved
password; NewPassword on registration offers to generate a strong one and save it
— which is the only way most people will ever use a strong password on your service. Checkout and the
address form tag every address field too, so a saved home address fills five fields with one tap.
Filtering input
Some fields have a shape, and the cheapest validation is not letting a wrong value in:
value = form.postalCode,
onValueChange = { v -> viewModel.updateForm { it.copy(postalCode = v.filter(Char::isDigit).take(5)) } },
filter(Char::isDigit).take(5) drops anything that is not a digit and anything past the
fifth, including a pasted "97201-1234". The state field does the same with take(2). This
is a convenience, not a guarantee: the value is still validated on submit, and the server validates it
again.
Keeping the button above the keyboard
When the keyboard opens, it covers the bottom half of the screen — usually including the submit
button. Two pieces make the layout move out of the way. The manifest asks for the window to be resized
(windowSoftInputMode="adjustResize"), and because the app draws edge to edge, the content
pads itself by the keyboard's height:
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = Spacing.lg)
.navigationBarsPadding()
// Lifts the form above the keyboard instead of letting it cover the button.
.imePadding()
.padding(bottom = Spacing.lg),
imePadding() adds bottom padding equal to the keyboard's current height, animated as
it slides in. Combined with verticalScroll, the button is always reachable by scrolling
rather than hidden behind the keyboard with no way to dismiss it. Forget it, and a customer on a small
phone cannot finish signing in.
Choices that are not text
A signed-in customer with saved addresses picks one instead of typing. That is a radio group, and
the detail that matters is the size of the tap target. A bare RadioButton is a 20dp
circle; the customer is aiming at the address text next to it. So the whole row is the control:
private fun AddressOption(label: String, selected: Boolean, enabled: Boolean, onSelect: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(selected = selected, enabled = enabled, role = Role.RadioButton, onClick = onSelect)
.padding(vertical = Spacing.xs)
.semantics(mergeDescendants = true) {},
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
selected = selected,
onClick = null,
enabled = enabled,
colors = RadioButtonDefaults.colors(selectedColor = PizzaTheme.colors.primary),
modifier = Modifier.padding(end = Spacing.sm).padding(vertical = (MinTouchTarget - Spacing.xl) / 2),
)
Text(label, style = MaterialTheme.typography.bodyMedium)
}
}
Modifier.selectable on the row makes the entire row tappable and gives it the radio
button role and selected state for TalkBack. onClick = null on the
RadioButton makes the circle purely visual, so it is not a second, separate focus stop
announcing the same choice twice. And the parent column carries Modifier.selectableGroup(),
so the options are announced as one group — "1 of 3".
Choosing "A new address" reveals the typed fields; choosing a saved one hides them and tells the
validator not to require them. The selection itself lives in the SavedStateHandle beside
the form, so it survives process death with everything else the customer chose.
Validation lives outside the composable
The checkout rules are a pure function: a form and a context in, a map of errors out. No Compose, no Android:
fun validate(form: CheckoutForm, context: Context): Map<CheckoutForm.Field, String> = buildMap {
if (form.customerName.isBlank()) {
put(CheckoutForm.Field.CUSTOMER_NAME, "Please tell us who the order is for.")
}
if (!isPlausibleEmail(form.email)) {
put(CheckoutForm.Field.EMAIL, "We need a valid email to send the receipt.")
}
// Address fields only exist for delivery, and only when no saved address is selected.
if (context.orderType != OrderType.DELIVERY || !context.needsTypedAddress) return@buildMap
if (form.addressLine1.isBlank()) {
put(CheckoutForm.Field.ADDRESS_LINE1, "We cannot deliver without a street address.")
}
if (form.city.isBlank()) put(CheckoutForm.Field.CITY, "City is required.")
if (form.state.isBlank()) put(CheckoutForm.Field.STATE, "State is required.")
if (!isFiveDigits(form.postalCode)) put(CheckoutForm.Field.POSTAL_CODE, "Five digits, please.")
}
Because it is plain Kotlin, it is tested on the JVM in milliseconds — a postal code test runs four
bad inputs through it without rendering a single field. The Context carries the two facts
that change the rules: pickup orders need no address, and a customer who picked a saved address does
not need to type one.
Email validation is deliberately loose — one @, something before it, a dotted domain after it. The only real test of an email address is sending to it; a strict regular expression mostly rejects real addresses with plus signs and long top-level domains.
When to show errors
Red text under a field the customer has not reached yet is nagging, not help. So errors appear only after the first attempt to submit — and from then on, they update as the customer types:
fun updateForm(transform: (CheckoutForm) -> CheckoutForm) {
val updated = transform(form.value)
savedStateHandle[KEY_FORM] = updated
// Re-validate as they type, but only once they have tried to submit — red text under a
// field the customer has not reached yet is nagging, not help.
if (hasAttemptedSubmit) _state.update { it.copy(fieldErrors = validate(updated)) }
}
fun placeOrder() {
hasAttemptedSubmit = true
val errors = validate(form.value)
_state.update { it.copy(fieldErrors = errors) }
if (errors.isNotEmpty() || _state.value.isSubmitting) return
// …
The submit guard also checks isSubmitting, so a second tap on a slow network does
nothing — the button is disabled while loading too, but the ViewModel does not trust the UI to have
done it.
Server errors land on fields too
Client-side validation is a courtesy. The server is the authority, and it reports problems per
field — "email: already registered". The API client turns those into a map
(ApiError.fieldErrors, in the error-handling lesson), and the sign-in sheet passes them to
the same error parameter:
label = "Full name",
error = state.fieldErrors["fullName"],
One parameter, two sources, one rendering. The customer sees the server's message exactly where a local one would have appeared.
Next
Sign-in, registration, the address form and the builder are all sheets. The next lesson is how Compose presents them — a sheet that appears by being composed and closes by leaving composition — and the one piece of state that stops two from opening at once.