Compose Text Fields
Updating Your Data
The TextFieldState keeps track of the value in the field, but we need to get its value so we can update the data in our database.
There are three main approaches to updating the data:
-
Update the data when the user goes "back"
-
Update the data when a "save" button is pressed. You provide a button in your app (on the top bar (recommended) or somewhere else in the Ui), and you only notify the caller that the data has changed when it's pressed.
-
Update the data as the user types it. This can be expensive and slow things down a bit in the app, but it has the advantage that if there's a bug in the app, the data is saved more often. If you had a long form with many entries, this could save much more user data in the case of an error.
In addition, you may want to also update the data if the user goes to the home screen or switches to another app via the recent app screen.
Note
Using rememberTextFieldState() will save the field text when the activity is
destroyed and recreated. This happens when the device configuration changes,
such as a screen rotation, or the user goes to the home screen or another app
(view recents). If you don't want the database updated until the user explicitly
saves or goes back from an edit screen, just handle the save for back or explicit
save. If you want to update the database when the app is paused, see that section
below.
Let's look at a more realistic Ui for editing a Person object.
Data and View Model
Starting with a simple Person definition:
data class Person(
val name: String,
val age: Int,
)
and a view model:
class MyViewModel: ViewModel() {
val personFlow = MutableStateFlow<Person>(Person("", 0))
fun updatePerson(person: Person) {
viewModelScope.launch {
// update the person in the database
// then update the flow (which might happen automatically
// depending on your database setup)
personFlow.value = person
}
}
}
PersonEditScreen Basics
We start with a PersonEditScreen that contains a title on the top bar and two text fields:
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PersonEditScreen(
person: Person,
onPersonChange: (Person) -> Unit,
) {
val nameState = rememberTextFieldState(person.name)
val ageState = rememberTextFieldState(person.age.toString())
// nested helper function to do the save if you repeat this logic
// (we'll use this in each of the following examples)
fun updatePerson() {
onPersonChange(
Person(
name = nameState.text.toString(),
age = ageState.text.toString().toInt(),
)
)
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(stringResource(R.string.person))
},
)
},
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
OutlinedTextField(
state = nameState,
label = { Text(stringResource(R.string.name))},
)
OutlinedTextField(
state = ageState,
label = { Text(stringResource(R.string.age))},
)
}
}
}
Saving With Explicit Save Button
First, let's explicitly add a "save" button at the top and perform the save. Note that the user will still have to press back to exit the screen.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PersonEditScreen(...) {
...
Scaffold(
topBar = {
TopAppBar(
title = { ... },
actions = {
IconButton(onClick = { updatePerson() }) {
Icon(
painter = painterResource(R.drawable.check_24),
contentDescription = stringResource(R.string.save)
)
}
}
)
},
) { ... }
}
If we want to automatically exit as well as save, we need to tell the back dispatch handler to perform a back press.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PersonEditScreen(...) {
...
val backDispatcher =
LocalOnBackPressedDispatcherOwner
.current
?.onBackPressedDispatcher
Scaffold(
topBar = {
TopAppBar(
title = { ... },
actions = {
IconButton(
onClick = {
updatePerson()
backDispatcher?.onBackPressed()
}
) { ... }
}
)
},
) { ... }
}
Saving on Pressing Back
Alternatively, we could autosave when the user presses back.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PersonEditScreen(...) {
...
val backDispatcher =
LocalOnBackPressedDispatcherOwner
.current
?.onBackPressedDispatcher
var interceptToSave by remember { mutableStateOf(true) }
val scope = rememberCoroutineScope()
BackHandler(enabled = interceptToSave) { // AAA
updatePerson() // BBB
interceptToSave = false // CCC
scope.launch { // DDD
// wait for next recomposition to remove the back handler
// before sending the back event to actually get out
awaitFrame() // EEE
backDispatcher?.onBackPressed() // FFF
}
}
Scaffold(...) { ... }
}
This is a little trickier.
| Step | Description |
|---|---|
| AAA | Override the default back handling with a BackHandler. Our override is only enabled if interceptToSave is true |
| BBB | Perform the actual save |
| CCC | We want to exit, so we need to disable our BackHandler or we'll get into an infinite loop when we tell the back dispatcher to go back. |
| DDD | Here's where it gets interesting. The BackHandler won't be disabled until the next recomposition. If we just tell the back dispatcher to go back, we'll keep running the current BackHandler, causing an infinite loop. So we kick off a coroutine to do some work. |
| EEE | The first part of that work is to wait until the next recomposition has finished. |
| FFF | Once we know the recomposition has finished, we know that our BackHandler is no longer registered. We can safely tell the back dispatcher to go back without looping infinitely |
Saving as the User Types
What if we want to save as the user types their values?
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PersonEditScreen(...) {
...
LaunchedEffect(nameState) {
snapshotFlow { nameState.text }.collect { updatePerson() }
}
LaunchedEffect(ageState) {
snapshotFlow { ageState.text }.collect { updatePerson() }
}
Scaffold(...) { ... }
}
Here we want to listen for changes to the name or age. We do that by creating a SnapshotFlow that we can collect each time the compose snapshot for the named field changes. Note that this will cause a lot of updates, so only use this approach if truly needed.
Saving When User Goes to Home or Another App
In addition to the above, you may want to save data when the user goes to the home screen or another application. You'll still want to choose one of the above approaches for saving the data normally.
Note
Using rememberTextFieldState() will save the field text when the activity is
destroyed and recreated. This happens when the device configuration changes,
such as a screen rotation, or the user goes to the home screen or another app
(view recents). This is usually sufficient for device rotation or brief user
visits to other apps.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PersonEditScreen(...) {
// to update when the user goes to the home screen
// You usually won't need to do this, as the text in the fields
// is saved and if the user returns it will be in the fields.
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_PAUSE) {
updatePerson()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
Scaffold(...) { ... }
}
Here we're hooking into the Android Activity lifecycle, listening for the "on pause" event, which happens whenever an Activity is about to become non-interactive. This happens during a configuration change or hibernating an Activity when switching to another app or the home screen.
A DisposableEffect starts a coroutine to perform the code in its lambda. The onDispose lambda is held until the Compose tree that we've emitted is being disposed. This allows us to perform paired setup and cleanup actions in our Compose Ui. Here we use it to attach a listener to the Android Activity lifecycle.