Skip to content

Compose Text Fields

Basics

TextFields allow the user to enter text in your application. They follow the same basic pattern we've seen for other composable functions:

  • Value to display passed in
  • Event function to inform caller that the user wants to change something

For example:

@Composable
fun SomeComposable() {
   var name by remember { mutableStateOf("") }

   TextField(
         value = name,
         onValueChange = { name = it },
   )
}

The TextField has some internal state to manage the cursor location, selections and the edit the user is making, but the value to be displayed is managed outside of it.

Note

As we'll see later, this separation of internal and external state can cause some nasty data races. Because of this (and some other awkward parts of the current API), Google is working on significant API changes that should come out in 2024.

In this module, we'll talk about some ways to mitigate races. You need to be careful how you use this deceptively-simple API...

There are three main types of text fields:

  • BasicTextField - the basic operations without decorations like background or outlines. You normally won't use this one unless you're creating a very custom field.
  • TextField - a text field with a background
  • OutlinedTextField - a textfield with a border around it

The TextField and OutlinedTextField also allow you to specify a placeholder that appears when no text is present, or a label, which acts like a placeholder until text is entered, and then moves/shrinks to remain visible as a label.

For example, here are the three types of text fields, with no entry, then after some text has been entered. All three are displaying the same state:

Fields without entries Fields with entries

Code for the above looks like:

var name by remember { mutableStateOf("") }
Column {
   BasicTextField(
         value = name,
         onValueChange = { name = it },
         modifier = Modifier.padding(8.dp).fillMaxWidth(),
   )
   TextField(
         value = name,
         onValueChange = { name = it },
         label = { Text("First name")},
         modifier = Modifier.padding(8.dp).fillMaxWidth(),
   )
   OutlinedTextField(
         value = name,
         onValueChange = { name = it },
         label = { Text("First name")},
         modifier = Modifier.padding(8.dp).fillMaxWidth(),
   )
}

We won't be covering all of the details of the text fields here, but there's quite a bit of customization, including the number of visible lines (as well as allowing/disallowing new lines), styles, but as a quick example, suppose we wanted to allow the user to enter and search for an email address (a bit contrived here, but roll with me)

OutlinedTextField(
      value = name,
      leadingIcon = {
         Icon(
            imageVector = Icons.Default.Email,
            contentDescription = "Email"
            )
         },
      onValueChange = { name = it },
      label = { Text("Email") },
      keyboardOptions = KeyboardOptions(
         imeAction = ImeAction.Search,
         keyboardType = KeyboardType.Email
      ),
      keyboardActions = KeyboardActions(
         onSearch = { ... },
      ),
      modifier = Modifier
         .padding(8.dp)
         .fillMaxWidth(),
)

Email field

A few things to note in the above:

  • Leading icons can help set off individual fields, if they're reasonably obvious.
  • Keyboard types are hints to the keyboard. Not all keyboards support all types. In this example, we hint to the keyboard that the field is for email. Many keyboards, such as the one displayed in the picture, will change some of the keys to make it easier for the user to enter the field. In this example, we see a convenient "@" key. Other keyboard types, such as Uri and Number may display differently.

    Uri keyboard Number keyboard

    But again - these are just hints and not all keyboards may support all hints.

  • Keyboard imeAction is a hint to the keyboard about treatment of the "enter" key. Keyboards typically use imeAction to determine what to display on the enter key. In this example, we asked for "search", so a magnifying glass is displayed. Other typical actions may represent sending a message or moving to the next field on the screen.

  • Keyboard actions allow you to specify what to do when the action is pressed.

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.

Bad text

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?

Fixing state races

The main solution is to mirror the state that will be displayed in the field, and ensure that updates to the real data happen in the proper order, or all at once on a user action (such as pressing a "save" button or navigating back from the edit page)

  • Mirror state during async processing

    • Local state updates the text fields
    • As fields change, inform caller to update the external state
    • External state is updated in database
    • Only works if any data translation locally and externally is the same
  • Mirrored state with final updates made on user action

    • Local state updates the text field
    • External state is not changed while user types
    • User pressing "save" or navigating back triggers
      • Inform caller of change
      • External data is updated and saved in database

Let's look at some examples

Saving data while the user is typing

I like this approach because all data being edited is kept in sync with all parts of the UI that use the data.

For example, suppose we have a list/detail setup; a list of items is on the left side of the screen, and the selected item is displayed on the right side of the screen. Some data will be shared - perhaps the name of an item is displayed in the list, but can be edited in the detail. As the user changes the name, it might be nice to change it in the list.

Note

This approach can have some interesting consequences when the list is sorted by the property being edited - you may need to perform some extra work to ensure the item being edited stays visible in the list.

One way to perform this type of processing:

  1. Pass in an id for the item to the edit screen
  2. Fetch the actual object in a LaunchedEffect, keyed by the id. It won't be re-fetched unless the id changes. This allows us to get an initial copy of the object that won't change while the user makes changes.
  3. Either
  4. Use this object and its properties as the text fields' state, or
  5. Extract each property into a separate State as the text field state (initialized to the fetched-object properties)
  6. As the user types
  7. Update the local state, triggering recomposition of the text fields, AND
  8. Tell the caller that the value has changed
  9. The caller can update the database, and it doesn't trigger recomposition, as its id hasn't changed. I recommend that you debounce this update so if the user types quickly, the database will only update when they pause typing

Code for this might look like

class PersonViewModel(
    private val personRepository: PersonRepository
): ViewModel() {
    val personFlow = personRepository.personFlow
    suspend fun fetchPerson(id: String): Person? =
        personRepository.getPersonById(id)

    // manual debouncing 
    //   (this implementation assumes all updatePerson calls come from the same thread)
    //   launch a coroutine and hold a pointer to it
    //     delay a certain amount of time
    //     perform the update
    //     clear the pointer (not really necessary)
    //   next time we try to run, before launching the coroutine
    //     if we have a pointer to the last coroutine run, cancel it
    //        if it's already finished, cancel doesn't do anything
    //        it it's still running, we abandon the work and start again
    // This has the effect of only running the work if there's a 500ms gap between calls
    // This is great for running work on each key press or fast mouse clicks
    private var updateJob: Job? = null
    fun updatePerson(person: Person) {
        updateJob?.cancel()
        updateJob = viewModelScope.launch(Dispatchers.IO) {
            delay(500)
            personRepository.update(person)
            updateJob = null
        }
    }
}

@Composable
fun PersonEditScreen(
    id: String?,
    onPersonChange: (Person) -> Unit,
    fetchPerson: suspend (String) -> Person?,
) {
    var person by remember { mutableStateOf<Person?>(null) }
    LaunchedEffect(key1 = id) {
        person = id?.let { fetchPerson(it) }
    }

    person?.let { fetchedPerson ->
        ...
        OutlinedTextField(
        value = fetchedPerson.name,
        label = { Text("Name")},
        onValueChange = {
            val newPerson = fetchedPerson.copy(name = it)
            person = newPerson
            onPersonChange(newPerson)
        },
        )
    }
}

This looks like

Debouncing

In this video, the "Person name in DB" field is separate from the person edit UI. It collects a Flow from the database, showing the name of the person we stored.

I type my last name fast enough that there is less than 500ms between each keystroke. Once I pause, the database is updated and my last name appears. I then delete several characters, pausing once in a while, until the field is blank. The database only updates during the pauses. I then type "Hello", pause, then " there", pause. Again, the text only updates in the database when I pause.

A safer way to debounce is to use the built-in (experimental) debounce() function in the view model. Here we use a MutableStateFlow as a "person update queue", debouncing the flow itself. Note that debounce() creates a new flow chained to the original flow.

We collect the flow in a viewModelScope-launched coroutine, which is canceled when the view model is dismissed.

The update call just becomes placing the person to update in the queue. The debounce() flow waits a bit before emitting the item, and will skip it if a new item comes in before the timeout.

@OptIn(FlowPreview::class)
class PersonViewModel(
): ViewModel() {
    private val personRepository: PersonRepository = PersonRepository()
    val personFlow = personRepository.personFlow
    suspend fun fetchPerson(id: String): Person? =
        personRepository.getPersonById(id)

    // using a debounced flow as a person-update queue
    private val personUpdateFlow = MutableStateFlow<Person?>(null)
    init {
        viewModelScope.launch(Dispatchers.IO) {
            personUpdateFlow.debounce(500).collect { person ->
                person?.let { personRepository.update(it) }
            }
        }
    }
    fun updatePerson(person: Person) {
        personUpdateFlow.value = person
    }
}

The behavior is the same as the previous example.

Updates made on user "save" or "back"

  • each field has its own state
  • initialize to properties of passed-in object
  • user presses explicit "save" button or navigates back
    • notify caller of all changes and exit
@Composable
fun PersonEditScreen(
    id: String?,
    onPersonChange: (Person) -> Unit,
    fetchPerson: suspend (String) -> Person?,
    onExit: () -> Unit,
) {
    var person by remember { mutableStateOf<Person?>(null) }
    LaunchedEffect(key1 = id) {
        person = id?.let { fetchPerson(it) }
    }

    person?.let { fetchedPerson ->
        ...
        OutlinedTextField(
            value = fetchedPerson.name,
            label = { Text("Name")},
            onValueChange = {
                person = fetchedPerson.copy(name = it)
            },
        )
        ...
        Button(
            onClick = {
               person?.let { onPersonChange(it) }
               onExit()
            }
        ) { Text("Save") }
    } ?: Text(text  = "Loading")
}

Note in this version of the PersonEditScreen we don't call onPersonChange when values of the TextField change; we only update the local Person. When the save button is pressed, we make the call to onPersonChanged and call onExit() to inform the caller we can navigate out.

This looks like the following (without the onExit() call so we can see multiple updates). Note that the Database isn't updated unless the save button is pressed. I've disabled debouncing in this example, though you may want to use it as the user could click many times on the button very quickly causing lots of saves (all with the same data).

Save-button updating

We can do similar processing using a BackHandler (to always save when the user navigates back) instead of the save button:

@Composable
fun PersonEditScreen(
    id: String?,
    onPersonChange: (Person) -> Unit,
    fetchPerson: suspend (String) -> Person?,
    onExit: () -> Unit,
) {
    var person by remember { mutableStateOf<Person?>(null) }
    LaunchedEffect(key1 = id) {
        person = id?.let { fetchPerson(it) }
    }

    BackHandler {
        person?.let { onPersonChange(it) }
        onExit()
    }

    person?.let { fetchedPerson ->
        ...
        OutlinedTextField(
            value = fetchedPerson.name,
            label = { Text("Name")},
            onValueChange = {
                person = fetchedPerson.copy(name = it)
            },
        )
    } ?: Text(text  = "Loading")
}

New TextField APIs in development

Heads up! Google is currently updating the TextField APIs. It's still early work but may be available in 2024. One of the main goals is to make the state management simpler (which would be incredibly welcome...)