Skip to content

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.