Compose Text Fields
State Races
As mentioned earlier, text fields have some internal state that needs to be synced to changes coming in from the outside. This internal state includes a copy of the currently-displayed text, the current cursor position, selection range. These are needed to interact with keyboards to perform suggestions, cut/copy/paste, etc.
In many cases, I like each change to be reported to the callers so they can immediately update their state. This allows more complex UIs to display updated states outside the text field. For example, if you have a larger screen and are displaying a list of items on the left and the details of a selected item on the right, editing the name of the item would immediately show in the list as you type.
This can be extremely error prone. If you pass the item being edited into the "edit screen" portion of the UI, and one of its properties into a TextField, you set yourself up for some serious races.
The user starts typing in the field. For the change to be displayed, we need to pass updated state into the TextField. But where does that updated state come from?
Suppose we have a setup like the following:
@Composable
fun PersonEditScreen(
person: Person?,
onPersonChange: (Person) -> Unit,
) {
person?.let {
Column {
OutlinedTextField(
value = person.name,
onValueChange = {
onPersonChange(person.copy(name = it))
},
)
OutlinedTextField(
value = person.age.toString(),
onValueChange = {
onPersonChange(person.copy(age = it.toInt()))
// ignore that this can fail if the text isn't a number...
},
)
}
} ?: Text(text = "Loading")
}
@Composable
fun Ui(
viewModel: MyViewModel,
) {
val person = viewModel.personFlow.collectAsStateWithLifecycle(initialValue = null)
PersonEditScreen(
person = person,
onPersonChange = { editedPerson ->
viewModel.updatePerson(editedPerson)
}
)
}
We're using Person.copy(...)
(assuming Person is a data class, which generates the copy
function) to create a new Person
instance with the same data, updating any data passed as parameters. We pass this new Person
to the viewModel
's updatePerson
function.
Many things can happen in that updatePerson
function, but let's assume it's expensive, might be running asynchronously, and take a bit to return. Perhaps the person is updated in the database.
While that update is happening, the user keeps typing, furiously. An updatePerson
call is sent for each added character.
The TextField
won't show any updates until we pass in a new value
. That won't happen until the database update occurs.
Best case, we would see a lag between when the user types, and when the new value is displayed.
But most often, things can get much, much worse. Depending on how updatePerson
is implemented, we might be launching a new coroutine for each call. The end result can be a bad mix of updates and syncs. Here I'm quickly typing "Scott Stanchfield" and then holding down backspace.
Oof! In other cases, the TextField may see that an update hasn't been made within a given amount of time, and it reports the previous value again.
So how can we fix this?