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
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.
// 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.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
| Parameter | Purpose | Practical decision |
|---|---|---|
| label | Persistent field identity | Use a meaningful label, even after someone types. |
| placeholder | An example while empty | Supplement the label; do not use it as the only instruction. |
| supportingText / isError | Guidance and error presentation | Describe the correction in words, alongside the error styling. |
| singleLine / minLines / maxLines | Line behavior in the value API | One line for a name; several visible lines for a note. |
| enabled = false | Unavailable interaction | Use when the field cannot currently be used. |
| readOnly = true | Readable but not editable | Useful when text must remain selectable or copyable. |
| modifier | Layout, focus observation, semantics | Accept 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
Follow an input from event to state
name = ""FocusunfocusedValidationnot shown yetOutlinedTextField(
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
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.
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")
}
}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
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")
}
},
)
}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.
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.
}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.
- Save one blank value: counter stays zero and an error appears.
- Save Ada: counter becomes one; the empty draft shows guidance, not an immediate error.
- Submit another valid name using keyboard Done: counter becomes two.
Compare a worked extension
// 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")| Symptom | Inspect first |
|---|---|
| Typing appears frozen | Does onValueChange update the state passed back as value? |
| Cursor jumps or spaces vanish | Are you rewriting the raw text on every edit? |
| Done accepts invalid input | Does it use the same validation and submission function? |
| Field loses its draft on rotation | Is 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?