Compose TextField — Taming the Beast
3 challenges of Compose’s TextField and how to work around them
One of the most commonly used UI components for text input of any application is the TextField, or in its most basic version, the EditText, or Material Design’s TextInputLayout + TextInputEditText. Although this sounds like a pretty simple and straightforward component, it has a lot of complexity. And today’s Modern Android development ecosystem adds special challenges to it.
If you’re an Android developer that wants to migrate or write your first project in Compose framework, this article is for you. Compose is a very powerful tool, but, at the same time, a very young framework that keeps evolving constantly. This article will focus on three challenges, or pitfalls, that Compose’s TextField component presents and how we can work around them. If you’re searching for solutions and bumped into this article, it might save you some time and effort.
Background
My team and I develop Melio’s Android application, which has many form screens for users to fill. So naturally, TextField is one of the corner-stone UI components that are used in the app. As we worked through migrating our app to use Compose, we decided to use a Material 3 component — the TextField. Little did we know then, that this component will present its own challenges.
It’s a simple-looking component, easy to theme and handle. Yet we quickly noticed it presented some buggy behaviors.
First Pitfall: State management
Always, and especially when using TextField in a reactive stream environment like Flow, use remember {} for the textField value.
Preferably not just for its current string, but for the whole TextFieldValue object, like so:
@Composable
fun CustomTextInputComponent(
textValue: String
onTextChanged: (String) -> Unit,
) {
var textFieldValue by remember {
mutableStateOf(TextFieldValue(text = textValue))
}
TextField(
value = textFieldValue,
onValueChange = { newValue ->
textFieldValue = newValue
onTextChanged(newValue.text)
}
)
}
I’ll explain.
The use of remember for the text value, is the recommended way by Jetpack documentation. As reactive streams work in an asynchronous manner, they can get the input out of sync. Its most noticeable effect is the unstable cursor position while typing:
Using a local Compose State will keep the input in sync, as a single synchronic source is channeling the input into the component. Much has already been written about it in this article.
Using remember caches the value set from outside the TextField between compositions. In order for this state to update on user input as well, we update it on each change in onValueChange().
If you look carefully, you’ll also notice that this piece of code contains a bug. Several, actually.
When creating a new instance of TextFieldValue, the selection argument that represents the current cursor’s position, defaults to TextRange.Zero.
This means, that when the TextField value is updated externally, the cursor position will default to the text start, and any additional user input will come before the initial text:
In order to address that, we can just init the selection value of the TextFieldValue to the text length.
That way, the local state will remember not only the text itself, but also the position of the cursor:
var textFieldValue by remember {
mutableStateOf(TextFieldValue(text = textValue, selection = TextRange(textValue.length)))
}
In the current setup, we only support setting the text once from the outside. Afterwards, the state updates every time the text in the field is changed by the user’s input.
But what if at some point we would want to update the textField from an external state again?
For example, If we have a button that needs to clear a field full of text, and its onClick() method triggers a state update in the viewModel, like so:
textFieldStateFlow.emit(“”)
Then the update won’t propagate to the UI, since the field uses our remembered state (remember?) and not the one set from outside after the initial call.
To fix this, we need to manage the compose cached State as a single source of truth for both input sources (user input and programmatic input), by adding the following lines:
//inside CustomTextInputComponent()
var textFieldValue by remember { mutableStateOf(TextFieldValue(text = textValue, selection = TextRange(textValue.length))) }
//update the state with text set from outside on each state change:
textFieldValue = TextFieldValue(text = textValue, selection = TextRange(textValue.length))
Note that in order to avoid a potential infinite loop on this implementation, the dataflow to the textField state must be unidirectional from the viewModel to the UI.
Second Pitfall: The label animation cannot be disabled
Unlike TextInputLayout, Jetpack Compose’s TextField imposes its design and doesn’t provide the option to disable the animation of the label from the underline to the top when the field gets focus.
Although it’s a nice animation, it’s not necessarily desired in all situations, and it’s a reasonable request to be able to disable it. Also, our design may require presenting the placeholder text alongside the label when the field is not focused, like so:
Turns out this isn’t possible as well with current API:
Looking at the source code, this is the way its built:
The placeholder is a composable being drawn as part of the DecorationBox — CommonDecorationBox of the BasicTextField, upon which the TextField is built. The CommonDecorationBox component features a built-in transition that cannot be disabled from the outside.
As we can see, if we want to present both the label and the placeholder simultaneously when the field is empty and unfocused, we simply can’t.
I simplified some of the source code, but it basically boils down to:
val placeholderOpacity by transition.animateFloat(
…
) {
when (it) {
InputPhase.Focused -> 1f
//this line makes it impossible to show label and placeholder simultaneously
InputPhase.UnfocusedEmpty -> if (showLabel) 0f else 1f
InputPhase.UnfocusedNotEmpty -> 0f
}
}
It seems the easiest way to work around it is to set the label to null and use our own custom label composable instead, but then we’ll have to handle its state theming and coloring ourselves.
Another option is to implement our own decoration box, but that will be an overhead as well.
I anticipate that on next updates Google will give the developers more control over basic attributes like this.
Third Pitfall: Height changes on focus change
When implementing a form screen using a Column of TextFields, a very un-smooth transition is shown when switching focus from one empty field to another. You can see that height of the underline changes during the animation:
I drilled down to the source code trying to understand this issue better.
It originated again in the decoration box applied to the BasicTextField within the TextField.
There are two types of decoration boxes, one for each type of Material 3 textField styles — Filled and Outlined:
The default type used is TextFieldDefaults.DecorationBox, which is based on the Filled textField style.
The internal implementation uses a CommonDecorationBox Composable, which handles each type a bit differently.
When using a TextFieldType.Outlined decoration box, this bug does not happen:
I tried to compare the implementation of the two types. Filled type uses a TextFieldLayout whereas Outlined type uses OutlinedTextFieldLayout. I noticed a main difference between the two: OutlinedTextFieldLayout specifies a measuring policy that takes further into account label changing dimensions during its transition, whereas TextFieldLayout does not.
I did not go any further than this, but it looks like a reasonable suspect for why the animation of the other type looks junky. So the workaround we chose together with our product designer was to use a BasicTextField with an OutlinedTextFieldDefaults.DecorationBox.
I issued a bug on Google’s issue tracker. As per writing these lines, it has progressed from P4 to P3 on their priority, so let’s hope it will be fixed on upcoming versions.
When discussing the above bugs at different threads of the issue tracker, it looks like they are in the process of fully re-writing the TextField component.
What’s next
I think a core UI component such as the TextField needs to become more mature in terms of robustness and flexibility before we can safely include it in our toolbox. But that’s a part of using a new framework — you get to help shape its evolution through feedback.
Looking forward to the next updates of Material 3!