Compose InputTransformation for Custom Keypad Validation


I am using the state-based BasicTextField API in Jetpack Compose with a custom numeric keypad. I want the TextFieldState to contain only the raw digits, while an OutputTransformation displays them as a 12-hour time.

For example, the input behaves like this:

raw       displayed
"5"    ==   5:00
"53"   ==   5:30
"534"  ==   5:34
"1130" ==  11:30

Invalid input should be rejected. For example, after ""5"", entering ""7"" would produce "5:70", so the state should remain ""5"". I have a small parser that is the source of truth for whether the entered digits represent a valid time:

private const val MaxTimeDigits = 4
private const val MinuteDigits = 2

private val HourRange = 1..12
private val MinuteRange = 0..59

private data class TwelveHourTime(
    val hour: Int,
    val minute: Int,
)

private fun String.asTwelveHourTime(): TwelveHourTime? {
    if (
        isEmpty() ||
        length > MaxTimeDigits ||
        !all(Char::isDigit)
    ) {
        return null
    }

    val hourLength = (length - MinuteDigits).coerceAtLeast(1)

    val hour = take(hourLength).toInt()
    val minute = drop(hourLength)
        .padEnd(MinuteDigits, '0')
        .toInt()

    return if (hour in HourRange && minute in MinuteRange) {
        TwelveHourTime(hour, minute)
    } else {
        null
    }
}

I then use the parser from an InputTransformation:

private val timeInputTransformation =
    InputTransformation.byValue { current, proposed ->
        val proposedText = proposed.toString()

        if (
            proposedText.isEmpty() ||
            proposedText.asTwelveHourTime() != null
        ) {
            proposed
        } else {
            current
        }
    }

And use an OutputTransformation only for formatting:

private val timeOutputTransformation = OutputTransformation {
    val time = asCharSequence()
        .toString()
        .asTwelveHourTime()

    if (time != null) {
        replace(
            0,
            length,
            "${time.hour}:${time.minute.toString().padStart(2, '0')}",
        )
    }
}

The field is then:

@Composable
fun TimeField() {
    val state = rememberTextFieldState()

    BasicTextField(
        state = state,
        readOnly = true,
        inputTransformation = timeInputTransformation,
        outputTransformation = timeOutputTransformation,
    )

    // Custom keypad would call this for each digit.
    Button(
        onClick = {
            state.edit {
                append("5")

                with(timeInputTransformation) {
                    transformInput()
                }
            }
        },
    ) {
        Text("5")
    }

    Button(
        onClick = {
            state.edit {
                if (length > 0) {
                    delete(length - 1, length)
                }

                with(timeInputTransformation) {
                    transformInput()
                }
            }
        },
    ) {
        Text("Backspace")
    }
}

The custom keypad changes TextFieldState programmatically:

state.edit {
    append(digit)

    with(timeInputTransformation) {
        transformInput()
    }
}

Since programmatic changes to TextFieldState do not automatically go through the inputTransformation passed to BasicTextField, I manually apply the same transformation after each keypad edit.

I’m trying to keep a single source of truth for validation, so invalid times never remain in TextFieldState, while OutputTransformation is only responsible for formatting.

Is this an idiomatic way to structure this with the state-based Compose text field API?

In particular:

  • Is InputTransformation the right place to validate/reject invalid time input?

  • Is manually calling transformInput() after a programmatic TextFieldState.edit appropriate?

  • Or would it be cleaner to keep the validation logic outside InputTransformation and reuse it from both the keypad and the transformation?

1
Aug 16 at 9:53 AM
User AvatarCompose Learner
#advice#android#kotlin#android-jetpack-compose

Accepted Answer

InputTransformation is a valid place to accept/reject edits. Just think about feedback- if I hit an invalid key and it just disappears, that doesn't feel right. Make sure to update some state to show why the input was invalid, or else it feels like a bug rather than a feature.

The TextFieldState is the state of the buffer- the actual text and cursor locations. You shouldn't need to call any validate functions after setting it, they should have been called before setting the data into the buffer. Because otherwise your buffer could be in a bad state, and there's no memory of what it was before (when it was in a valid state). Basically the buffer should never be in a bad state. This is also how it works on keyboard input- the transformation is called before setting the state, not after.

If you want a function that validates and then sets a value to the field, I would recommend writing a class that contains a TextFieldState and provides mutators to the state that validate before editing the state. This may set you down the road of deciding to use a TextFieldValue version of TextField rather than a TextFieldState version. That's a valid choice as well. The TextFieldState one really is best used for cases where you don't want to hook or alter the input much.

User AvatarGabe Sechan
Aug 16 at 4:48 PM
1