TextField & User Input
RecipeBox's Add/Edit Recipe screen is the first place we need real text input — a title, a prep
time, ingredients, steps. Compose's TextField and OutlinedTextField look
like they'd "just work" the way an XML EditText with a bound variable might, but
Compose has an important difference worth understanding up front: there is no two-way data
binding. You always have to wire the value up yourself.
TextField and OutlinedTextField
Both composables render a text input; OutlinedTextField draws a bordered outline
around the field (Material 3's more common look), while TextField uses a filled
background instead. Which one you pick is purely visual — everything else about how they work is
identical:
@Composable
fun RecipeTitleInput() {
var title by remember { mutableStateOf("") }
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text("Recipe title") },
modifier = Modifier.fillMaxWidth()
)
}
The Mandatory value / onValueChange Pair
This is the core rule: TextField is a completely "dumb" display of whatever
String you pass into value — it does not remember what the user typed on
its own. Every keystroke fires onValueChange with the field's new proposed text, and
it is your responsibility to store that new text somewhere and feed it back into
value. If you forget the onValueChange half, the field will visually
reject every keystroke, because value never changes and Compose keeps re-rendering the
same old text:
// Broken: value never updates, so the field appears frozen — you cannot type into it
var title by remember { mutableStateOf("") }
OutlinedTextField(value = title, onValueChange = { /* forgot to update title! */ })
// Correct: onValueChange writes the new text back into the state that value reads from
OutlinedTextField(value = title, onValueChange = { title = it })
value) and events always flow up (onValueChange). It's
more typing than XML's two-way android:text="@={viewModel.title}" data-binding, but it
makes the data flow through your composable completely explicit and traceable.KeyboardOptions: Choosing the Input Type
RecipeBox's prep-time field should only accept digits, and showing the phone's numeric keypad
instead of the full alphabet keyboard is both friendlier and reduces bad input.
KeyboardOptions configures exactly that:
@Composable
fun PrepMinutesInput(prepMinutesText: String, onPrepMinutesChange: (String) -> Unit) {
OutlinedTextField(
value = prepMinutesText,
onValueChange = onPrepMinutesChange,
label = { Text("Prep time (minutes)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
}
Other common KeyboardType values include Email, Password
(which also needs a visualTransformation to mask characters), and Text
(the default). Note that KeyboardType.Number only changes which keyboard is
shown — it doesn't stop a pasted non-numeric string from arriving in
onValueChange, so real validation still needs to happen in your own code, as the next
section covers.
Worked Example: Add Recipe Title Field with Validation
A recipe's title field is required — RecipeBox shouldn't let someone save a recipe
with a blank name. OutlinedTextField has built-in support for an error state via
isError, plus a supportingText slot for the message itself:
@Composable
fun RecipeTitleField(modifier: Modifier = Modifier) {
var title by remember { mutableStateOf("") }
var touched by remember { mutableStateOf(false) }
val showError = touched && title.isBlank()
OutlinedTextField(
value = title,
onValueChange = {
title = it
touched = true
},
label = { Text("Recipe title") },
isError = showError,
supportingText = {
if (showError) {
Text("Title is required")
}
},
modifier = modifier.fillMaxWidth()
)
}
touched avoids showing "Title is required" the instant the screen opens, before the
user has typed anything at all — the error only appears once they've interacted with the field and
left it empty. This version keeps its own state internally, which works for a quick example, but it
means nothing outside this composable — like a parent "Save Recipe" button — can see what the user
typed. The next lesson fixes exactly that.
RecipeTitleField into a stateless, reusable composable whose caller owns the
title value.