Vivek Modi
08/16/2026, 10:07 AMBasicTextField 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.Vivek Modi
08/16/2026, 10:07 AMraw 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()
}
}Vivek Modi
08/16/2026, 10:13 AMTextFieldState 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?Chrimaeon
08/16/2026, 5:11 PMMark Murphy
08/17/2026, 5:25 PMVivek Modi
08/17/2026, 10:47 PM