Android – Forms, Text Input and Validation

August 26, 20268 min readUpdated 10/11/2026

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 rememberSaveable inside 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.Email puts @ and .com on the first page and turns off autocorrect, which otherwise "fixes" addresses into words.
  • KeyboardType.Number on the ZIP, Phone on the phone number, Password on passwords (which also disables learning and suggestions).
  • KeyboardCapitalization.Words on names and cities, Characters on the two-letter state. The default for a plain field is no capitalisation, which leaves every customer's name in lowercase.
  • ImeAction.Next turns the return key into a forward arrow; Done into 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.