Created
November 6, 2021 16:15
-
-
Save vganin/36e62e4f243187d966a18b47358ba566 to your computer and use it in GitHub Desktop.
Jetpack Compose duration input field
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@Composable | |
fun DurationPicker( | |
state: MutableState<Duration>, | |
modifier: Modifier = Modifier, | |
) { | |
DurationPicker( | |
value = state.value, | |
onValueChange = { state.value = it }, | |
modifier = modifier, | |
) | |
} | |
@Composable | |
fun DurationPicker( | |
value: Duration, | |
onValueChange: (Duration) -> Unit, | |
modifier: Modifier = Modifier, | |
) { | |
/** Converts CharSequence in format "hhmmss" to Duration */ | |
fun CharSequence.toDuration(): Duration { | |
val hoursSequence = subSequence(0, 2) | |
val minutesSequence = subSequence(2, 4) | |
val secondsSequence = subSequence(4, 6) | |
val sequenceToTimeMultiplier = listOf( | |
hoursSequence to SECONDS_PER_MINUTE * MINUTES_PER_HOUR, | |
minutesSequence to SECONDS_PER_MINUTE, | |
secondsSequence to 1L, | |
) | |
val radix = 10 | |
var seconds = 0L | |
for ((sequence, timeMultiplier) in sequenceToTimeMultiplier) { | |
var baseMultiplier = 1 | |
for (char in sequence.reversed()) { | |
val int = char.code - '0'.code | |
seconds += int * baseMultiplier * timeMultiplier | |
baseMultiplier *= radix | |
} | |
} | |
return Duration.seconds(seconds) | |
} | |
/** Converts Duration to String in format "hhmmss" */ | |
fun Duration.asString(): String { | |
return toComponents { hours, minutes, seconds, _ -> | |
"${hours.coerceAtMost(99).twoDigits()}${minutes.twoDigits()}${seconds.twoDigits()}" | |
} | |
} | |
val internalStringState = remember { mutableStateOf(value.asString()) } | |
if (internalStringState.value.toDuration() != value) { | |
internalStringState.value = value.asString() | |
} | |
fun updateInternalStringState(newInternalStringState: String) { | |
internalStringState.value = newInternalStringState | |
onValueChange(newInternalStringState.toDuration()) | |
} | |
fun enterDigit(char: Char): Boolean { | |
return if (char.isDigit() && internalStringState.value.first() == '0') { | |
updateInternalStringState(internalStringState.value.drop(1) + char) | |
true | |
} else { | |
false | |
} | |
} | |
fun removeDigit(): Boolean { | |
updateInternalStringState('0' + internalStringState.value.dropLast(1)) | |
return true | |
} | |
@Composable | |
fun formatInternalState(): String { | |
val hoursString = stringResource(R.string.duration_picker_hours) | |
val minutesString = stringResource(R.string.duration_picker_minutes) | |
val secondsString = stringResource(R.string.duration_picker_seconds) | |
return StringBuilder().apply { | |
append(internalStringState.value.subSequence(0, 2)) | |
append(hoursString) | |
append(' ') | |
append(internalStringState.value.subSequence(2, 4)) | |
append(minutesString) | |
append(' ') | |
append(internalStringState.value.subSequence(4, 6)) | |
append(secondsString) | |
}.toString() | |
} | |
val inputService = LocalTextInputService.current!! | |
val textInputSession: MutableState<TextInputSession?> = remember { mutableStateOf(null) } | |
val focusRequester = remember { FocusRequester() } | |
val interactionSource = remember { MutableInteractionSource() } | |
val isFocused = interactionSource.collectIsFocusedAsState().value | |
var focusRect: Rect? by remember { mutableStateOf(null) } | |
if (isFocused && textInputSession.value == null) { | |
textInputSession.value = inputService.startInput( | |
value = TextFieldValue(), | |
imeOptions = ImeOptions( | |
singleLine = true, | |
autoCorrect = false, | |
keyboardType = KeyboardType.Number, | |
imeAction = ImeAction.Done, | |
), | |
onEditCommand = { operations -> | |
operations.forEach { operation -> | |
when (operation) { | |
is BackspaceCommand -> removeDigit() | |
is CommitTextCommand -> operation.text.forEach(::enterDigit) | |
} | |
} | |
}, | |
onImeActionPerformed = { action -> | |
if (action == ImeAction.Done) { | |
focusRequester.freeFocus() | |
} | |
} | |
) | |
} else if (!isFocused && textInputSession.value != null) { | |
textInputSession.value?.let(inputService::stopInput) | |
textInputSession.value = null | |
} | |
DisposableEffect(focusRect, textInputSession.value) { | |
val focusRectValue = focusRect | |
val textInputSessionValue = textInputSession.value | |
if (focusRectValue != null && textInputSessionValue != null) { | |
textInputSessionValue.notifyFocusedRect(focusRectValue) | |
} | |
onDispose {} | |
} | |
Row( | |
modifier = modifier | |
.focusableBorder() | |
.focusRequester(focusRequester) | |
.focusable(interactionSource = interactionSource) | |
.onKeyEvent { | |
// FIXME(https://issuetracker.google.com/issues/188119984): Should keep only onEditCommand | |
if (it.type != KeyEventType.KeyDown) return@onKeyEvent false | |
if (it.key == Backspace) { | |
removeDigit() | |
} else { | |
enterDigit(it.utf16CodePoint.toChar()) | |
} | |
} | |
.clickable { focusRequester.requestFocus() } | |
.onGloballyPositioned { layoutCoordinates -> focusRect = layoutCoordinates.boundsInWindow() } | |
.padding(8.dp) | |
) { | |
Text( | |
text = formatInternalState(), | |
modifier = Modifier | |
.align(Alignment.CenterVertically) | |
.weight(1.0f), | |
style = LocalTextStyle.current.copy(textAlign = TextAlign.Center), | |
) | |
Spacer(Modifier.width(8.dp)) | |
CloseIcon { | |
onValueChange(Duration.ZERO) | |
} | |
} | |
} | |
@Composable | |
private fun RowScope.CloseIcon(onClick: () -> Unit) { | |
Box( | |
modifier = Modifier | |
.align(Alignment.CenterVertically) | |
.size(16.dp, 16.dp) | |
.clickable( | |
onClick = onClick, | |
interactionSource = remember { MutableInteractionSource() }, | |
indication = rememberRipple(bounded = false) | |
) | |
) { | |
Icon(imageVector = Icons.Default.Close, contentDescription = null) | |
} | |
} | |
private fun Modifier.focusableBorder(isError: Boolean = false): Modifier = composed { | |
var isFocused by remember { mutableStateOf(false) } | |
val activeColor = MaterialTheme.colors.primary.copy(alpha = ContentAlpha.high) | |
val inactiveColor = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) | |
val activeErrorColor = MaterialTheme.colors.error.copy(alpha = ContentAlpha.high) | |
val inactiveErrorColor = MaterialTheme.colors.error.copy(alpha = ContentAlpha.disabled) | |
val borderColor = when { | |
isFocused -> when { | |
isError -> activeErrorColor | |
else -> activeColor | |
} | |
else -> { | |
when { | |
isError -> inactiveErrorColor | |
else -> inactiveColor | |
} | |
} | |
} | |
val borderWidth by animateDpAsState(if (isFocused) 2.dp else 1.dp) | |
this | |
.onFocusChanged { focusState -> | |
if (isFocused == focusState.isFocused) { | |
return@onFocusChanged | |
} | |
isFocused = focusState.isFocused | |
} | |
.border( | |
border = BorderStroke(borderWidth, borderColor), | |
shape = MaterialTheme.shapes.small, | |
) | |
} | |
private val TWO_DIGITS_FORMAT = DecimalFormat("00") | |
private fun Int.twoDigits() = TWO_DIGITS_FORMAT.format(this) | |
private const val SECONDS_PER_MINUTE = 60L | |
private const val MINUTES_PER_HOUR = 60L | |
@Preview | |
@Composable | |
private fun Preview() { | |
val sharedState = remember { mutableStateOf(Duration.hours(1) + Duration.minutes(2) + Duration.seconds(3) + Duration.milliseconds(4)) } | |
Column { | |
DurationPicker(sharedState) | |
DurationPicker(sharedState) | |
DurationPicker(remember { mutableStateOf(Duration.minutes(1) + Duration.seconds(2) + Duration.milliseconds(3)) }) | |
DurationPicker(remember { mutableStateOf(Duration.seconds(1) + Duration.milliseconds(2)) }) | |
DurationPicker(remember { mutableStateOf(Duration.milliseconds(1)) }) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment