My original attempt used seperate TextFields for each pin number. When a pin number was entered it would go to next TextField. On typing Backspace in an empty TextField it would clear the previous TextField.
The Backspace is where things get tricky. Clearing the previous TextField may cause the same TextField to clear the previous one. Even tracking the current focus TextField did not work resulting in the same behaviour. I solved this by doing a state update that would signal to move the focus forward or backward.
In the end this solution does work, but it runs into one problem, entering a pin code with double digits. Typing something like 2009, the second 0 would get eaten up. The focus has not moved on to the next TextField.
I want something more reliable.
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.*
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component1
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component2
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component3
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component4
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import bikeaway_kmp.app.shared.generated.resources.Res
import bikeaway_kmp.app.shared.generated.resources.unlock_btn
import com.bikeaway.bikehubs.ui.buttons.AppButtonText
import com.bikeaway.bikehubs.ui.buttons.PrimaryButton
import com.bikeaway.bikehubs.ui.theme.AppTheme
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.jetbrains.compose.resources.stringResource
@Composable
fun PinInputView(
initialPin : String? = null,
onNext: (pin: String) -> Unit,
) {
val focusManager = LocalFocusManager.current
val pinFieldState = rememberPinFieldState(
initialPin = initialPin
)
val pinCode by pinFieldState.pinCode.collectAsState("")
val hasPinCode by pinFieldState.hasPinCode.collectAsState(false)
Box(
modifier = Modifier
.imePadding()
.fillMaxSize()
) {
Box(
modifier = Modifier
.fillMaxSize()
.wrapContentSize()
.clickable {
if (pinFieldState.hasFocus) {
focusManager.clearFocus()
pinFieldState.hasFocus = false
}
}
) {
PinField(
pinFieldState = pinFieldState
)
}
Box(
modifier = Modifier
.fillMaxSize()
.wrapContentSize(Alignment.TopStart)
) {
Column {
Text("Initial Pin: $initialPin")
}
}
Box(
modifier = Modifier
.padding(bottom = 40.dp)
.fillMaxSize()
.wrapContentSize(align = Alignment.BottomCenter)
) {
PrimaryButton(
enabled = hasPinCode,
onClick = {
onNext(pinCode)
}
) {
AppButtonText(stringResource(Res.string.unlock_btn))
}
}
}
}
@Composable
private fun PinField(
pinFieldState: PinFieldState = PinFieldState()
) {
val (pin1ref, pin2ref, pin3ref, pin4ref) = FocusRequester.createRefs()
val focusManager = LocalFocusManager.current
LaunchedEffect(pinFieldState.changeFocusState) {
when (pinFieldState.changeFocusState) {
ChangeFocusState.NEXT -> {
focusManager.moveFocus(FocusDirection.Next)
pinFieldState.changeFocusState = ChangeFocusState.NONE
}
ChangeFocusState.PREVIOUS -> {
focusManager.moveFocus(FocusDirection.Previous)
pinFieldState.changeFocusState = ChangeFocusState.NONE
}
else -> Unit
}
}
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier.fillMaxWidth()
) {
SinglePinField(
pinState = pinFieldState.pinStates[0],
onFocusNext = {
pinFieldState.changeFocusState = ChangeFocusState.NEXT
},
modifier = Modifier
.focusRequester(pin1ref)
.focusProperties {
previous = pin1ref
next = pin2ref
}
.onFocusChanged {
if (it.hasFocus) {
pinFieldState.hasFocus = true
}
}
)
SinglePinField(
pinState = pinFieldState.pinStates[1],
onFocusNext = {
pinFieldState.changeFocusState = ChangeFocusState.NEXT
},
modifier = Modifier
.focusRequester(pin2ref)
.focusProperties {
previous = pin1ref
next = pin3ref
}
.onFocusChanged {
if (it.hasFocus) {
pinFieldState.hasFocus = true
}
}
.onPreviewKeyEvent {
if (
it.key == Key.Backspace &&
pinFieldState.pinStates[1].value.isEmpty()
) {
pinFieldState.pinStates[0].value = ""
pinFieldState.changeFocusState = ChangeFocusState.PREVIOUS
return@onPreviewKeyEvent true
}
return@onPreviewKeyEvent false
}
)
SinglePinField(
pinState = pinFieldState.pinStates[2],
onFocusNext = {
pinFieldState.changeFocusState = ChangeFocusState.NEXT
},
modifier = Modifier
.focusRequester(pin3ref)
.focusProperties {
previous = pin2ref
next = pin4ref
}
.onFocusChanged {
if (it.hasFocus) {
pinFieldState.hasFocus = true
}
}
.onPreviewKeyEvent {
if (
it.key == Key.Backspace &&
pinFieldState.pinStates[2].value.isEmpty()
) {
pinFieldState.pinStates[1].value = ""
pinFieldState.changeFocusState = ChangeFocusState.PREVIOUS
return@onPreviewKeyEvent true
}
return@onPreviewKeyEvent false
}
)
SinglePinField(
pinState = pinFieldState.pinStates[3],
onFocusNext = {
pinFieldState.changeFocusState = ChangeFocusState.NEXT
},
modifier = Modifier
.focusRequester(pin4ref)
.focusProperties {
previous = pin3ref
next = pin4ref
}
.onFocusChanged {
if (it.hasFocus) {
pinFieldState.hasFocus = true
}
}
.onPreviewKeyEvent {
if (
it.key == Key.Backspace &&
pinFieldState.pinStates[3].value.isEmpty()
) {
pinFieldState.pinStates[2].value = ""
pinFieldState.changeFocusState = ChangeFocusState.PREVIOUS
return@onPreviewKeyEvent true
}
return@onPreviewKeyEvent false
}
)
}
}
@Composable
private fun SinglePinField(
pinState: MutableState<String>,
modifier: Modifier = Modifier,
onFocusNext: () -> Unit
) {
val textFieldState = rememberTextFieldState()
val regex = remember { Regex("[0-9]*") }
val focusManager = LocalFocusManager.current
LaunchedEffect(textFieldState.text) {
pinState.value = textFieldState.text.toString()
if (textFieldState.text.isNotEmpty()) {
onFocusNext()
}
}
LaunchedEffect(pinState.value) {
if (pinState.value.isEmpty()) {
textFieldState.clearText()
} else {
textFieldState.setTextAndPlaceCursorAtEnd(pinState.value)
}
}
Box(
modifier = Modifier.width(50.dp)
) {
TextField(
textFieldState,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next
),
lineLimits = TextFieldLineLimits.SingleLine,
inputTransformation = InputTransformation.maxLength(1).then {
if (!asCharSequence().matches(regex)) {
revertAllChanges()
}
},
textStyle = MaterialTheme.typography.titleLargeEmphasized.copy(
textAlign = TextAlign.Center
),
modifier = modifier
)
}
}
private class PinFieldState {
var hasFocus by mutableStateOf(false)
var changeFocusState by mutableStateOf(ChangeFocusState.NONE)
// var currentFocus by mutableStateOf<Int?>(null)
val pinStates = Array(4) {
mutableStateOf("")
}
private val pinStateFlow: Flow<Array<String>> = snapshotFlow {
Array(pinStates.size) { pinStates[it].value }
}
val pinCode: Flow<String> = pinStateFlow.map { values ->
values.fold(StringBuilder()) { builder, value ->
if (value.isNotEmpty()) {
builder.append(value)
}
builder
}.toString()
}
val hasPinCode: Flow<Boolean> = pinStateFlow.map { values ->
values.all { it.isNotEmpty() }
}
// val hasFocus: Boolean get() = currentFocus != null
//
// fun hasFocusFor(index: Int) = hasFocus && currentFocus == index
//
// fun clearFocus() {
// currentFocus = null
// }
}
private enum class ChangeFocusState {
NEXT,
PREVIOUS,
NONE
}
@Composable
private fun rememberPinFieldState(
initialPin: String? = null
): PinFieldState {
return remember {
val state = PinFieldState()
initialPin?.let { initialPin ->
initialPin.forEachIndexed { index, ch ->
state.pinStates[index].value = ch.toString()
}
}
state
}
}
/* Preview */
@Composable
@Preview
private fun PinInputViewPreview() {
AppTheme {
Scaffold { innerPadding ->
Box(
modifier = Modifier
.padding(innerPadding)
) {
PinInputView(
onNext = {},
)
}
}
}
}
My solution is to scrap separate TextFields and have one BasicTextField that receives input but is not visible. Then create a Composable that shows the input as desired.
The solution is a combination of:
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.*
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalTextToolbar
import androidx.compose.ui.platform.TextToolbar
import androidx.compose.ui.platform.TextToolbarStatus
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import bikeaway_kmp.app.shared.generated.resources.Res
import bikeaway_kmp.app.shared.generated.resources.unlock_btn
import com.bikeaway.bikehubs.ui.buttons.AppButtonText
import com.bikeaway.bikehubs.ui.buttons.PrimaryButton
import com.bikeaway.bikehubs.ui.misc.PADDING_BOTTOM
import com.bikeaway.bikehubs.ui.theme.AppTheme
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.jetbrains.compose.resources.stringResource
import kotlin.math.max
import kotlin.math.roundToInt
@Composable
fun PinInputView(
initialPin : String? = null,
onNext: (pin: String) -> Unit,
) {
val focusManager = LocalFocusManager.current
val pinFieldState = rememberPinFieldState(
initialPin = initialPin
)
val hasPinCode by pinFieldState.hasPinCode.collectAsState(false)
Box(
modifier = Modifier
.imePadding()
.fillMaxSize()
.clickable {
if (pinFieldState.hasFocus) {
pinFieldState.hasFocus = false
focusManager.clearFocus()
}
}
) {
Box(
modifier = Modifier
.fillMaxSize()
.wrapContentSize()
) {
PinField(
state = pinFieldState
)
}
Box(
modifier = Modifier
.padding(bottom = PADDING_BOTTOM)
.fillMaxSize()
.wrapContentSize(align = Alignment.BottomCenter)
) {
PrimaryButton(
enabled = hasPinCode,
onClick = {
onNext(pinFieldState.pinCode)
}
) {
AppButtonText(stringResource(Res.string.unlock_btn))
}
}
}
}
@Composable
private fun PinField(
state: PinFieldState
) {
val textFieldState = rememberTextFieldState(
initialText = state.pinCode ?: ""
)
val regex = remember { Regex("[0-9]*") }
LaunchedEffect(textFieldState.text) {
val text = textFieldState.text
state.pinCode = text.toString()
state.pinStates.forEachIndexed { index, state ->
if (index < text.length) {
state.value = text[index].toString()
} else {
state.value = ""
}
}
}
Box(
modifier = Modifier
.height(IntrinsicSize.Max)
) {
val customTextSelectionColors = TextSelectionColors(
handleColor = Color.Transparent,
backgroundColor = Color.Transparent,
)
CompositionLocalProvider(
LocalTextToolbar provides EmptyTextToolbar,
LocalTextSelectionColors provides customTextSelectionColors
) {
BasicTextField(
state = textFieldState,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.NumberPassword,
imeAction = ImeAction.Done
),
inputTransformation = InputTransformation.maxLength(4).then {
if (!asCharSequence().matches(regex)) {
revertAllChanges()
}
},
lineLimits = TextFieldLineLimits.SingleLine,
textStyle = TextStyle(
color = Color.Transparent
),
cursorBrush = SolidColor(Color.Transparent),
modifier = Modifier
.fillMaxSize()
.onFocusChanged {
if (it.isFocused) {
state.hasFocus = true
}
}
)
PinRowLayout {
state.pinStates.forEach { state ->
SinglePinField(
state = state
)
}
}
}
}
}
@Composable
fun SinglePinField(
state: MutableState<String>
) {
Box(
modifier = Modifier
.border(
1.dp,
MaterialTheme.colorScheme.onSurface,
shape = RoundedCornerShape(12.dp)
)
.height(56.dp)
.padding( horizontal = 16.dp)
.wrapContentSize()
) {
Text("0",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.visible(false)
)
Text(state.value,
style = MaterialTheme.typography.titleLarge
)
}
}
@Composable
fun PinRowLayout(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
val placeables = measurables.map { measurable ->
measurable.measure(constraints)
}
val (allWidth, maxHeight) = placeables.fold(Pair(0, 0)) { acc, placeable ->
Pair(
acc.first + placeable.width,
max(acc.second, placeable.height)
)
}
val remainingWidth = constraints.maxWidth - allWidth
val aroundSpacing = (remainingWidth.toDouble() * (1.0/((placeables.size + 3).toDouble()))).roundToInt()
//
layout(constraints.maxWidth, maxHeight) {
var xPosition = aroundSpacing * 2
placeables.forEach { placeable ->
placeable.placeRelative(xPosition, 0)
xPosition += placeable.width + aroundSpacing
}
}
}
}
private class PinFieldState(
initialPin: String? = null
) {
var hasFocus by mutableStateOf(false)
val pinStates = Array(4) {
mutableStateOf("")
}
var pinCode: String = initialPin ?: ""
private val pinStateFlow: Flow<Array<String>> = snapshotFlow {
Array(pinStates.size) { pinStates[it].value }
}
val hasPinCode: Flow<Boolean> = pinStateFlow.map { values ->
values.all { it.isNotEmpty() }
}
}
@Composable
private fun rememberPinFieldState(
initialPin: String? = null
): PinFieldState {
return remember {
val state = PinFieldState(
initialPin = initialPin
)
initialPin?.let { initialPin ->
initialPin.forEachIndexed { index, ch ->
state.pinStates[index].value = ch.toString()
}
}
state
}
}
object EmptyTextToolbar : TextToolbar {
override fun hide() {}
override fun showMenu(
rect: Rect,
onCopyRequested: (() -> Unit)?,
onPasteRequested: (() -> Unit)?,
onCutRequested: (() -> Unit)?,
onSelectAllRequested: (() -> Unit)?
) {}
override fun showMenu(
rect: Rect,
onCopyRequested: (() -> Unit)?,
onPasteRequested: (() -> Unit)?,
onCutRequested: (() -> Unit)?,
onSelectAllRequested: (() -> Unit)?,
onAutofillRequested: (() -> Unit)?
) {}
override val status: TextToolbarStatus
get() = TextToolbarStatus.Hidden
}
@Composable
@Preview
fun PinInputViewPreview() {
AppTheme {
Scaffold { innerPadding ->
Box(
modifier = Modifier.padding(innerPadding)
) {
PinInputView(
onNext = {}
)
}
}
}
}
NebulaFox