When building a design system for the Android app at inDrive, we implemented a reusable TextArea component using androidx.compose.foundation.text.BasicTextField.
One of the basic requirements was straightforward: limit the maximum number of characters a user can enter.
Jetpack Compose seems to already support this through InputTransformation. However, after exploring the implementation more closely, we discovered a subtle behavior that can lead to a completely unusable TextField in certain scenarios. This article explores:
- how Jetpack Compose TextField max length works internally
- why the default implementation may cause issues
- and how we solved it in production.
Understanding InputTransformation in BasicTextField
BasicTextField exposes a parameter called inputTransformation.
@Suppress("ComposableLambdaParameterPosition")
@Composable
fun BasicTextField(
//...
inputTransformation: InputTransformation? = null,
//...
)
InputTransformation works only for user-generated input.
This includes:
- typing on the keyboard
- pasting text
- drag & drop
- accessibility input
- automated tests
However, it does NOT apply when the state changes programmatically.
This becomes important when the UI contains a custom Paste button that inserts text from the clipboard directly into TextFieldState.
The Built-in maxLength Transformation
Jetpack Compose provides a helper function:
fun InputTransformation.maxLength(maxLength: Int): InputTransformation =
this.then(MaxLengthFilter(maxLength))
But when we looked deeper into the implementation, things became more interesting.
The MaxLengthFilter Implementation
// This is a very naive implementation for now, not intended to be production-ready.
private data class MaxLengthFilter(private val maxLength: Int) : InputTransformation {
override fun TextFieldBuffer.transformInput() {
if (length > maxLength) {
revertAllChanges()
}
}
}
The most interesting part is the comment:
“This is a very naive implementation for now, not intended to be production-ready.”
Finding such a comment inside a foundation library is always surprising.
It also highlights an important lesson: comments are not rules.
The Scenario That Breaks Everything
Let’s walk through a real scenario.
- The user presses a Paste button
- The app programmatically inserts a long text into the TextField
InputTransformationis not applied- The user tries to edit the text
InputTransformationruns- All changes are reverted
At this point, the TextField becomes effectively unusable.
Every attempt to modify the text triggers a revert.
Why This Happens
The difference lies in how TextField state changes are applied.
Programmatic change
inline fun edit(block: TextFieldBuffer.() -> Unit) {
val mutableValue = startEdit()
try {
mutableValue.block()
commitEdit(mutableValue)
} finally {
finishEditing()
}
}
User input
internal inline fun editAsUser(
inputTransformation: InputTransformation?,
restartImeIfContentChanges: Boolean = true,
block: TextFieldBuffer.() -> Unit,
)
Only editAsUser applies InputTransformation.
A Practical Fix
The simplest and most reliable solution is to observe the TextField state and trim the text manually.
LaunchedEffect(textFieldState.text) {
if (textFieldState.text.length > maxCharacters) {
textFieldState.edit {
delete(maxCharacters, length)
}
}
}
This approach:
- prevents the TextField from becoming blocked
- guarantees the text length limit
- provides a better user experience
Final Thoughts
Always look deeper into the frameworks you rely on.
Even foundational libraries may contain:
- unexpected edge cases
- experimental implementations
- or design tradeoffs that surface only in real-world usage.
In many ways, software engineers are a bit like entomologists — studying strange behaviors hidden deep inside complex systems.
Library version used:
androidx.compose.foundation:foundation-android:1.10.1
