I want to apply a visual transformation to a BasicTextField in Jetpack Compose. I've tried some code, but I'm facing several issues. For example, the cursor doesn't stay in the correct position, and when pressing backspace, the cursor moves to the wrong place.
I need the BasicTextField to always display a specific string (e.g., "ABC-") that cannot be removed, even with backspace or clear actions. Additionally, I want to limit the input and apply masking to format the text like this: "ABC-1234-2345-6366".
Could someone help me implement this correctly?
@Preview(showBackground = true)
@Composable
private fun SampleTextFieldView() {
var textFieldValue by remember { mutableStateOf(TextFieldValue("ABC-")) }
BasicFieldView(
textFieldValue,
onValueChange = { onValueChange ->
val digitOnly = onValueChange.text.filter { it.isDigit() }
val formattedString = buildString {
append("Abc-")
digitOnly.forEachIndexed { index, digit ->
if (index % 4 == 0 && index !=0 ){
append("-")
}
append(digit)
}
}
textFieldValue = onValueChange.copy(formattedString)
}
)
}
@Composable
fun BasicFieldView(
textFieldValue: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
) {
Column(
modifier = Modifier.fillMaxSize()
) {
BasicTextField(
modifier = Modifier.fillMaxWidth(),
value = textFieldValue,
onValueChange = {
onValueChange(it)
},
)
}
}
First off, you should use the new BasicTextField overload that uses a TextFieldState
instead of a TextFieldValue
. That fixes a lot of issues around monitoring updates to the value of the text field and accepts an InputTransformation
to modify the data the user entered and an OutputTransformation
to modify how the text field's value should be displayed.
Now, to accomodate your requirements the following is needed:
To delimit the input to 12 characters and ensure only digits can be entered, you need InputTransformations. For the maximum length you can use the built-in InputTransformation.maxLength(12)
. To limit the input to digits only you can create a custom InputTransformation:
object DigitsOnlyTransformation : InputTransformation {
override val keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
override fun TextFieldBuffer.transformInput() {
if (!asCharSequence().isDigitsOnly()) {
revertAllChanges()
}
}
}
The keyboardOptions
can also be directly specified at the BasicTextField, but I find it more clear to bundle all restrictions regarding the digit-only requirement at one place. The TextFieldBuffer
can be modified as required. revertAllChanges()
ignores the latest changes. This behaves a little bit different than your implementation that would filter out all non-digits. This only matters when the user enters multiple characters at once, like pasting mixed digits and non-digits from the clipboard. You can adapt this as needed.
These two InputTransformations can be chained together and then passed to the BasicTextField like this:
BasicTextField(
...
inputTransformation = InputTransformation.maxLength(12)
.then(DigitsOnlyTransformation),
)
To provide the mask with the dashes for the display only you need to create a new OutputTransformation. OutputTransformations works the same as InputTransformations, except that they do not change the text field's value, the output is only used for the display in the UI:
@Stable
data class GroupingOutputTransformation(
private val groupSize: Int,
private val groupDelimiter: String,
) : OutputTransformation {
override fun TextFieldBuffer.transformOutput() {
repeat((length - 1) / groupSize) {
insert(it + (it + 1) * groupSize, groupDelimiter)
}
}
}
Use it like this:
BasicTextField(
...
outputTransformation = GroupingOutputTransformation(4, "-"),
)
It inserts -
after every 4
characters (except at the end).
To display an unmodifyable prefix before the text field's value you could use an OutputTransformation (you can try this out by adding insert(0, "ABC-")
after the repeat loop). While at first glance this seems to work as intended, the cursor can be placed before the prefix. Although all characters entered will be inserted after the prefix, this may still be undesired and could potentially confuse the user. Although there is a placeCursorAtEnd()
function available for the TextFieldBuffer that could remedy this, it only has an effect when used in an InputTransformation, not an OutputTransformation.
The alternative is to display the prefix outside of the text field. That can easily be accomplished by using the BasicTextField's decorator
parameter:
BasicTextField(
...
decorator = { innerTextField ->
Row {
Text(text = "ABC-", style = TextStyle.Default)
innerTextField()
}
},
)
innerTextField
is the actual text field which you can lay out as desired, in this case in a Row with the prefix placed before it. You need to apply the same TextStyle to the text that you set for the BasicTextField (where you use the default TextStyle.Default
) so it seamlessly blends together.
Everything put together, your BasicFieldView would look like this:
@Composable
fun BasicFieldView(
state: TextFieldState,
textStyle: TextStyle = TextStyle.Default,
prefix: String = "ABC-",
maxLength: Int = 12,
groupSize: Int = 4,
groupDelimiter: String = "-",
) {
Column(
modifier = Modifier.fillMaxSize(),
) {
BasicTextField(
state = state,
modifier = Modifier.fillMaxWidth(),
inputTransformation = InputTransformation.maxLength(maxLength)
.then(DigitsOnlyTransformation),
textStyle = textStyle,
outputTransformation = GroupingOutputTransformation(groupSize, groupDelimiter),
decorator = { innerTextField ->
Row {
Text(text = prefix, style = textStyle)
innerTextField()
}
},
)
}
}
The constants are extracted to parameters with default values so you can easily adapt them when needed.
As you can see the textFieldValue
and onValueChange
parameters are now replaced with state
. As mentioned at the beginning, TextFieldState is the modern way to use TextFields now (it will also be available for the styled Material 3 TextField
and OutlinedTextField
starting with the upcoming version 1.4.0 of androidx.compose.material3:material3
). With a TextFieldState you don't have an onValueChange callback anymore, that is automatically handled internally.
Just pass a rememberTextFieldState()
to BasicFieldView. Save it in a variable and observe its text
property for changes. It is a MutableState, so changes will automatically trigger a recomposition wherever it is used. Alternatively you can use val state = TextFieldState()
in your view model. If you want to trigger some action whenever the value changes wrap it in a snapshotFlow:
snapshotFlow { state.text }
Now the resulting flow can be collected outside of Compose or inside a LaunchedEffect.