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.

Defining state

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

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 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 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.

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.

Source link