Blog Infos
Author
Published
Topics
Published
Topics

This 2 part blog series covers a dive into the past, present and future of text fields in Jetpack Compose. Discover brand new BasicTextField2.

TL;DR: The Compose text team is building the next generation of TextField APIs. You can try them out right now. BasicTextField2 is available in latest foundation 1.6.0 alphas, in package text2. Give feedback to the team through the various channels described below.

Caveat

Compose is built in layers: Material -> Foundations -> UI. TextField and OutlinedTextField are material components that add styling on top of BasicTextField in the foundations layer. In this article, we’ll be describing BasicTextField2which aims to replace BasicTextField.
Note BasicTextField2is a temporary name while the API is in development.

Compose layers and where each API fits.

Past

Meet TextField (orBasicTextField) APIs to implement a text field in Jetpack Compose:

var textField by rememberSaveable { mutableStateOf("") }
TextField(
  value = textField,
  onValueChange = { textField = it },
)

This simple API has a series of shortcomings, most notably:

It is way too easy to introduce async behaviours to the process of updating BasicTextField state using the onValueChange callback, causing erratic unexpected behaviours. This problem is complex and has been described to extensive detail in this blog post:

VisualTransformation is a big source of confusion and bugs.
Take for instance phone formatting. Typically you’d want your phone to be modified as you enter it by adding spaces, dashes, parenthesis. To implement this using the VisualTransformation API, you need to specify a mapping between positions of the initial characters and the transformed characters.

Manual mappings in Visual Transformations

Writing these mappings is not an easy process.

Formatting phones when extending VisualTransformation

private val phoneNumberFilter = VisualTransformation { text ->
    val trimmed = if (text.text.length >= 10) text.text.substring(0..9) else text.text
    val filled = trimmed + "_".repeat(10 - trimmed.length)
    val res = "(" + filled.substring(0..2) + ") " + filled.substring(3..5) + "-" +
        filled.substring(6..9)
    TransformedText(AnnotatedString(text = res), phoneNumberOffsetTranslator(text.text))
}

ValidatingOffsetMapping class was introduced and its purpose was to validate VisualTransformation and throw exceptions with meaningful messages if the mappings are incorrect. It was built as a tool to give more information to developers, better understand crashes and debug them. Before this change, you would get a crash anyways, with little information to help you find the problem. However this change did not fix the root issue.

The overall TextField API could be improved too. For instance, configuring single line for a field is easy enough: we just define singleLine = true. However, it’s unclear what the merged result of doing something like this is:

TextField(
  value = textField,
  onValueChange = { textField = it },
  singleLine = true,
  minLines = 2,
  maxLines = 4
)

Plus the current API doesn’t allow to easily identify exactly what has changed in the editing process, leaving the full diff to the developer. Imagine you want to display a list of changes each user made in a document collaboration tool.

TextField(
  value = text,
  // What changed here exactly? You only have the old value and the new value.
  onValueChange = { newFullText -> /***/ }
)

Considering all of these and few other limitations, the team got together to brainstorm typical text field use cases, imagining what would the ideal text field API looks like.
The following section is an exploration of how these ideas came to life and became BasicTextField2.

Present
Defining state
We have a sign up screen. For the username field, before we’d have something like this to define our text field:

OUR VIDEO RECOMMENDATION

, ,

Migrating to Jetpack Compose – an interop love story

Most of you are familiar with Jetpack Compose and its benefits. If you’re able to start anew and create a Compose-only app, you’re on the right track. But this talk might not be for you…
Watch Video

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer for Jetpack Compose
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engin ...
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer f ...
Google

Jobs

No results found.

var username by rememberSaveable { mutableStateOf("") }
BasicTextField(
    value = username,
    onValueChange = { username = it },
)

The new API BasicTextField2 works in the following way:

val username = rememberTextFieldState()
BasicTextField2(state = username)

We no longer have a callback, which will prevent the mistakes of introducing async behaviours described earlier.

Use rememberTextFieldState to define your state variable of type TextFieldState. You can configure an initialText for convenience, and initial selection and cursor position. Defined with this API, the state survives recomposition, configuration and process death.
Using rememberTextFieldState is a familiar way to instantiate state, like havingrememberLazyListState to define LazyListState or rememberScrollState forScrollState.

If you need to apply business rules to your state or need to hoist state to the ViewModel, then you define a variable of type TextFieldState like this:

// ViewModel

val username = TextFieldState()


// Compose

BasicTextField2(state = viewModel.username)
Styling

The material wrappers for BasicTextField2 are not ready yet (BasicTextField2 sits in the foundations layer). Use TextStyle block to modify colorfontSizelineHeight, etc. Use all the modifier system, in particular border modifier to achieve a style like the one in the example above. Or usedecorator param for an even more customised look of the text field container (like OutlinedTextField achieves with decorationBox).

val username = rememberTextFieldState()
BasicTextField2(state = username,
    textStyle = textStyleBodyLarge,
    modifier = modifier.border(...),
    decorator = @Composable {}
)
Line Limits

The new API removes the ambiguity to configure single/multi line, by providing a lineLimits param of type TextFieldLineLimits:

BasicTextField2(
    /***/
    lineLimits = TextFieldLineLimits.SingleLine
)

BasicTextField2(
    /***/
    lineLimits = TextFieldLineLimits.Multiline(5, 10)
)

SingleLine: the text field is always a single line tall, ignores newlines and scrolls horizontally when the text overflows.

Multiline: define min and max lines. The text starts being minHeightInLines tall, once you reach the end of the field the text wraps to the next line. As you keep typing the text grows until it is maxHeightInLines tall. If you keep typing out of bounds, vertical scroll is enabled.

State observation

Let’s see how to observe the username state to do some validation, and apply business rules to it.

API: textAsFlow

Let’s say we want to show an error when our username picked is not available.

The code may look as follows:

// ViewModel

// Hoist text field state to the ViewModel outside of composition
val username = TextFieldState()

// Observe changes to username text field state
val userNameHasError: StateFlow<Boolean> =
    username.textAsFlow()
        .debounce(500)
        .mapLatest { signUpRepository.isUsernameAvailable(it.toString()) }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = false
        )

We define a variable userNameHasError which will be true or false depending on the validation run on the username.
We can observe the mutable state text inside TextFieldState with snapshotFlow API and for every new value we can call an asynchronous validation.

snapshotFlow { username.text }

This way to observe compose state with snapshotFlow is very common, so BTF2 API offers textAsFlow method that does the same thing for simplicity, you can just call this extension function.
We use debounce from Flow API to wait before firing a validation giving some buffer time to the user to finish typing.
Then we use mapLatest to call our validation on every new char typed.Finally stateIn will convert this state into a StateFlow.

Then on the UI we read userNameHasError. If this state is true we show a Text below the username.

// Compose
    if (signUpViewModel.userNameHasError) {
    // show error label
}

API: forEachTextValue

Another convenient alternative to perform async operations on each character typed would be to use textAsFlow().collectLatest(block) and fire an async validation. There is another extension for this called forEachTextValue which gives us a suspend function scope to make a request on each value change.

We can rewrite the example we saw before, defining a suspend method validateUsername. Then for each new text value we make our async request and set the value of userNameHasError. This happens in the ViewModel. In Compose we define a LaunchedEffect to observe the typing events, and observe userNameHasError state to modifiy the view.

The code would look something like:

// ViewModel

var userNameHasError by mutableStateOf(false)
suspend fun validateUsername() {
    username.forEachTextValue {
        userNameHasError = signUpRepository.isUsernameAvailable(it.toString())
    }
}

// Compose

LaunchedEffect(Unit) {
    signUpViewModel.validateUsername()
}

if (signUpViewModel.userNameHasError) {
    // show error label
    Text(
        text = "Username not available. Please choose a different one."
    )
}

onValueChange override

Let’s see one more way to observe state changes. BTF2 allows to define the state in the following way:

var username by rememberSaveable { mutableStateOf("") }

BasicTextField2(
    value = username,
    onValueChange = {
        username = it
    }
)

Which is the exact same shape as the current API BasicTextField v1, to ease the transition between these two APIs, for convenience for simpler cases in which you just have a string, and because this shape is familiar. It might seem that you could make the exact same mistake we covered earlier, of adding accidental async delays to the editing process. So you might be wondering how is this an improvement?
While this API looks the exact same in BTF2 and BTF1, under the hood they behave very differently.

BasicTextField2(
    // this is ignored if BTF2 is in focus
    value = username,
    onValueChange = {
        // *** potential async stuff *** //

        username = it
    }
)

Whatever happens inside the lambda is always called, so if you’re making async calls those will be fired. However, the text field won’t update the state with your programmatic changes while the field is in focus, it respects only the input coming from typing events. The result is that the user won’t see any changes applied inside lambda reflected in the UI. The field has the control at all times making it snappy and responsive, avoiding the async issues. Only when your field loses focus, any programatic changes you have done last will be applied.
This internal mechanism guarantees the integrity of the text field state during the editing process, because it keeps one source of truth at all times (either user through the software keyboard (IME) or the developer), ignoring programatic changes if it needs to. The counterpart is that you might expect your changes to be reflected in the UI at a certain point in time but they won’t be.

The recommendation is to only use this API shape for the simplest cases when a String value is enough to represent your state and you don’t need control while the text field is focused e.g. modifying the text, selection or cursor in flight.
For more complex cases, use the API shape we already covered with 
TextFieldState.

Editing text programatically

There’s a multitude of cases when we need or want to manipulate the content of the text field manually. The simplest example is having a Clear button to erase the content of the text field.

To access the edit session to make programatic changes to the text field content, there is a new API called TextFieldBuffer.

class TextFieldBuffer : Appendable {

    val length: Int

    val hasSelection: Boolean
    var selectionInChars: TextRange


    fun replace(...)
    fun append(...)
    fun insert(...)
    fun delete(...)

    fun selectAll(...)
    fun selectCharsIn(...)
    fun placeCursorAfterCharAt(...)

}

This class holds information about the text state like length and selection if any, methods to explicitly modify it the text like replace, append, insert and delete, and to change selection and cursor position.

To implement the Clear button, we can write something like this:

// ViewModel
val username = TextFieldState()

fun clearField() {
    username.edit {
        // we’re in TextFieldBuffer land
       delete(0, length)
    }
}

Notice we use the edit function to access the TextFieldBuffer and from there we get access to all the methods we described above. In the above example, we are deleting the contents of the text field state. And this method is so common that BTF2 has a helper extension function that does exactly this called clearText. So the code can be simplified to just:

// ViewModel
val username = TextFieldState()

fun clearField() {
    username.clearText()
}

Another example, imagine you have a markdown text editor (e.g. Github’s PR description), and you want to wrap some selected text in “**” characters to indicate this text is bold:

// Compose

val markdownText = rememberTextFieldState()
BasicTextField2(
    state = markdownText
)

// ...

Button(
    onClick = {
        markdownText.edit {
            if (selectionInChars.length > 0) {
                insert(selectionInChars.start, "**")
                insert(selectionInChars.end, "**")
            }
        }
    },
) {
    Text(text = "B", fontWeight = Bold)
}

For further examples of how to write markdown mutators for TextFieldBuffer see this snippet.

Or select all text in case of an error:

// ViewModel
val username = TextFieldState()

val userNameHasError: StateFlow<Boolean> =
    username.textAsFlow()
        .mapLatest {
            val hasError = signUpRepository.isUsernameAvailable(it.toString())
            if (hasError) highlight()
            return@mapLatest hasError
        }
        .stateIn(...)


fun highlight() {
    username.edit { selectAll() }
}

At this point you might realise that this way to command the UI to edit the text is opposed to the paradigm of declarative UI. And that’d be correct. There is no real state observation for the purpose of updating the UI. This is left entirely to the internal component.
This is an intentional design choice for the text field, due to the tightness of its update loop, to prevent state synchronisation issues.
It’s similar to methods you find in ScrollableState like animateScrollTo, where you command the UI to do something.

To be continued…

So far we’ve covered how to:

  • manage state with the new API to prevent state synchronicity issues
  • do basic styling including using decorator and line limits
  • solve for common scenarios when observing state and applying business rules
  • edit the value of the text field programmatically with TextFieldBuffer

See part 2️⃣, where we go through the new APIs to filter input, do visual transformations and write password fields. And we also cover the future of the API.

Find all the code used for this post in the Github playground repo.

Happy Composing! 👋

BasicTextField2: A TextField of Dreams [1/2]
BasicTextField2: A TextField of Dreams [2/2]

Thank you Anastasia Soboleva, Halil Özercan and Zach Klippenstein on the Jetpack Compose Text team for their thorough reviews.

This post is the written version of the DroidCon London 2023 talk TextField in Jetpack Compose: past, present and future.

This article was previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
TL;DR: The Compose text team is building the next generation of TextField APIs. You…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu