Phase 2 · Building Interactive UIsModule 18~55 min read

TextField: State, Validation & Keyboard Behavior

Trace input events into state, build helpful validation, manage focus and IME actions, and compare value-based and state-based text APIs.

A text field is a conversation, not just a box

A good input preserves what someone types, explains the expected format, and offers a useful recovery when validation fails. Build that behavior deliberately with TextField and OutlinedTextField.

Prerequisites: state, state hoisting, callbacks, and Column. By the end, you can trace an edit from keyboard to state to screen, build an accessible validated field, and make keyboard submission follow the same rules as the visible button. Allow time to run the examples on a device.

Current API guidance

Android's current documentation marks value-based text fields as deprecated and recommends state-based fields for new code. The full examples here use the value-based API to match this course's pinned project and explain existing applications. See the state-based example and migration guide below; check your resolved Material 3 version before choosing an overload.

Understand the editing contract

In the value-based overload, value is the text currently displayed. onValueChange receives a proposed replacement, including typing, paste, deletion, and input-method edits. Store it immediately in the state that feeds value. Do not wait for a network request before updating that state; asynchronous validation is separate from accepting an edit.

EditingContract.kt
// Inside a @Composable function:
var draft by rememberSaveable { mutableStateOf("") }
OutlinedTextField(
    value = draft,
    onValueChange = { newText -> draft = newText },
    label = { Text("Display name") },
    singleLine = true,
)
// The callback requests a change; the next value renders it.
Focused excerpt inside a composable. Imports appear in the complete example below.

A String is sufficient for this exercise. The TextFieldValue overload additionally gives you selection and composing-region information when you must control them. Hoist the value and callback when another component, such as a submit button or summary, needs the same input.

Choose parameters for a reason

ParameterPurposePractical decision
labelPersistent field identityUse a meaningful label, even after someone types.
placeholderAn example while emptySupplement the label; do not use it as the only instruction.
supportingText / isErrorGuidance and error presentationDescribe the correction in words, alongside the error styling.
singleLine / minLines / maxLinesLine behavior in the value APIOne line for a name; several visible lines for a note.
enabled = falseUnavailable interactionUse when the field cannot currently be used.
readOnly = trueReadable but not editableUseful when text must remain selectable or copyable.
modifierLayout, focus observation, semanticsAccept a modifier in reusable field components.

Filled and outlined fields share the same editing idea. Select one consistent visual style for a form. Prefer Material fields when you want labels, indicators, and standard decoration; BasicTextField is a lower-level editing building block requiring more design work.

Explore field states

Composable lab · Change & observe

Follow an input from event to state

Try a blank name, one character, then a valid name.

Use the name you want classmates to see.

0/30 characters

Changes are kept in this browser demonstration only.

State ownername = ""FocusunfocusedValidationnot shown yet
State down, events up. Typing reports a new value. The owner stores it and the field receives the updated value. Read-only permits focus and selection while preventing edits.
OutlinedTextField(
    value = name,
    onValueChange = { name = it },
    label = { Text("Display name") },
    singleLine = true,
    readOnly = false,
    isError = touched && name.trim().length !in 2..30,
)
// Validate on blur or submit. Show a supporting error message.
// Save handles a user event; it does not run during composition.

Interactive browser model with a Kotlin configuration excerpt. It does not run Compose. Font metrics, scaling, touch targets, focus, and assistive technology must be checked on an Android device or emulator.

Predict which state should show an error before changing the controls. Then compare empty input, spaces, valid input, and an attempted submission. Repeat the actual Kotlin version on Android to verify its focus, keyboard, and accessibility behavior.

Separate validity from when to show an error

These are different questions: is the current value acceptable, and has the user reached a point where an error is useful? This lesson uses a small product rule: a trimmed display name contains 2–30 Kotlin characters. That rule is deliberately simple; production name rules should consider Unicode and the product's requirements.

Keep the raw draft while editing. Trimming on every keystroke makes spaces difficult to enter and can disturb input. Compute a normalized candidate for validation and submission. Initially show helpful instructions. After blur or a save attempt, show a corrective message if the candidate is invalid. Once fixed, the error disappears because validity is recomputed from the current draft.

One submission path

A visible button and the keyboard Done action should call the same function. That function rechecks the rules, so an alternative input route cannot bypass validation.

Build a complete display-name editor

Create DisplayNameEditor.kt in your app's package, retain your own package declaration, and add these imports. In your generated Activity's existing setContent theme block, call DisplayNameEditor(). The example needs the standard Compose Foundation, Runtime, UI, and Material 3 dependencies already used by the course.

DisplayNameEditor.kt
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp

@Composable
fun DisplayNameEditor(modifier: Modifier = Modifier) {
    var draft by rememberSaveable { mutableStateOf("") }
    var attempted by rememberSaveable { mutableStateOf(false) }
    var hasFocused by rememberSaveable { mutableStateOf(false) }
    var hasBlurred by rememberSaveable { mutableStateOf(false) }
    var savedName by rememberSaveable { mutableStateOf("") }
    val focusManager = LocalFocusManager.current
    val normalized = draft.trim()
    val valid = normalized.length in 2..30
    val showError = !valid && (attempted || hasBlurred)

    fun save() {
        attempted = true
        if (valid) {
            savedName = normalized
            focusManager.clearFocus()
        }
    }

    Column(
        modifier = modifier
            .imePadding()
            .verticalScroll(rememberScrollState())
            .padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        OutlinedTextField(
            value = draft,
            onValueChange = {
                draft = it
                savedName = "" // Editing starts a new, unsaved draft.
            },
            label = { Text("Display name") },
            placeholder = { Text("For example, Ada") },
            supportingText = {
                Text(if (showError) "Enter 2–30 characters." else "Shown on your profile.")
            },
            isError = showError,
            singleLine = true,
            keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
            keyboardActions = KeyboardActions(onDone = { save() }),
            modifier = Modifier.fillMaxWidth().onFocusChanged { state ->
                if (state.isFocused) hasFocused = true
                else if (hasFocused) hasBlurred = true
            },
        )
        Button(onClick = { save() }) { Text("Save name") }
        if (savedName.isNotEmpty()) Text("Saved name: $savedName")
    }
}
Complete composable with explicit imports. English strings keep the lesson focused; move user-facing text into Android string resources in a localized app.

Run these checks: the empty screen has guidance; saving spaces shows a correction; entering Ada clears the error; both Save and keyboard Done display the same saved name. Editing again clears the old success message because the changed draft has not been saved. Rotate after typing a non-sensitive draft: rememberSaveable restores this small input. The saved confirmation is only demonstration state, not a database record.

Treat the keyboard as another input route

KeyboardType.Email or KeyboardType.Number requests a convenient keyboard layout. It does not validate the input: pasted text and hardware keyboards can still supply unexpected characters. Parse numbers with a nullable conversion such as toIntOrNull(), then check the accepted range.

Use ImeAction.Next for moving through a form and ImeAction.Done for its final field. In this value-based example, KeyboardActions handles the action. LocalFocusManager can move or clear focus. Do not request focus on every recomposition, which can steal it while the person is editing another field.

A scrolling form with imePadding() can keep content reachable above the software keyboard. Coordinate this with the screen's Scaffold and system-inset policy so padding is applied once. Test a small window with the keyboard open, landscape orientation, a hardware keyboard, and larger system text.

Make password visibility explicit

PasswordExample.kt
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation

@Composable
fun PasswordExample() {
    // Deliberately not saved across Activity recreation.
    var password by remember { mutableStateOf("") }
    var visible by remember { mutableStateOf(false) }
    OutlinedTextField(
        value = password,
        onValueChange = { password = it },
        label = { Text("Password") },
        singleLine = true,
        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
        visualTransformation = if (visible) VisualTransformation.None
            else PasswordVisualTransformation(),
        trailingIcon = {
            TextButton(onClick = { visible = !visible }) {
                Text(if (visible) "Hide password" else "Show password")
            }
        },
    )
}
Standalone value-based password example. Call PasswordExample() under your app theme to inspect it; no authentication is performed.

The action says what it will do: Show password becomes Hide password. Masking changes the display; the underlying value remains plaintext in memory. Do not log it, place it in saved UI state, or include it in analytics. Prefer the state-based secure field APIs when supported by your project, and clear credentials when their use is complete.

Recognize the state-based API

Android's current guidance recommends TextFieldState for new text-input work. It coordinates text, selection, and composing edits in one holder. This course's main worked example uses the established value-based overload to match earlier lessons. These are two API families: do not mix their parameter lists.

StateBasedNameField.kt
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable

@Composable
fun StateBasedNameField() {
    val name = rememberTextFieldState()
    OutlinedTextField(
        state = name,
        label = { Text("Display name") },
        lineLimits = TextFieldLineLimits.SingleLine,
    )
    // Read name.text; edit through name.edit { ... } when needed.
}
Alternative API: use a Material 3 version exposing the state overload. Check your resolved dependency and Android Studio API reference before adopting it; do not change versions just to paste this comparison.

State-based fields use lineLimits instead of singleLine, and input/output transformation APIs instead of the value API's visual-transformation approach. Changing initialText during recomposition does not reset an existing state holder; use its edit operations. See the linked migration guide for a deliberate migration of the entire field.

Practice: save and reset safely

Extend the editor with a successful-save counter. After a valid save, retain the confirmation, clear the draft, reset the error timing, and dismiss focus. An invalid attempt must preserve the draft and leave the counter unchanged.

  1. Save one blank value: counter stays zero and an error appears.
  2. Save Ada: counter becomes one; the empty draft shows guidance, not an immediate error.
  3. Submit another valid name using keyboard Done: counter becomes two.
Compare a worked extension
DisplayNameEditor-extension.kt
// Extend DisplayNameEditor; add beside the other state:
var savedCount by rememberSaveable { mutableStateOf(0) }

// Replace the valid branch in save():
if (valid) {
    savedName = normalized
    savedCount += 1
    draft = ""
    attempted = false
    hasFocused = false
    hasBlurred = false
    focusManager.clearFocus()
}

// Add at the end of Column:
Text("Successful saves: $savedCount")
Targeted changes to the complete DisplayNameEditor example.
SymptomInspect first
Typing appears frozenDoes onValueChange update the state passed back as value?
Cursor jumps or spaces vanishAre you rewriting the raw text on every edit?
Done accepts invalid inputDoes it use the same validation and submission function?
Field loses its draft on rotationIs appropriate non-sensitive state saved or hoisted?

Review & quick check

Key takeaways

  • Accept edits immediately; validate and submit as separate concerns.
  • A label identifies the field; supporting text explains guidance or recovery.
  • Keyboard type is an input hint, never a validation guarantee.
  • Preserve ordinary drafts appropriately; keep passwords out of saved UI state.
  • Choose one text-field API family and match its parameters and dependency support.

Quick check

1. A numeric keyboard is selected. What must happen before using the input as an age?

2. Why keep the raw draft separate from draft.trim() in this editor?

3. You switch to TextField(state = ...). Which other change belongs to that API family?

Official references