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:

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:

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.

  1. The user presses a Paste button
  2. The app programmatically inserts a long text into the TextField
  3. InputTransformation is not applied
  4. The user tries to edit the text
  5. InputTransformation runs
  6. 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:

Final Thoughts

Always look deeper into the frameworks you rely on.

Even foundational libraries may contain:

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