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
andOutlinedTextField
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 (or
BasicTextField
) 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
username
field, before we’d have something like this to define our text field:Job Offers
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 for
ScrollState.
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
color
, fontSize
, lineHeight
, etc. Use all the modifier system, in particular border modifier to achieve a style like the one in the example above. Or use
decorator 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 inScrollableState
likeanimateScrollTo
, 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