如何设计一个更好的OTP/PIN输入框
我的最初尝试为每个引脚号码使用单独的 TextField。输入一个引脚号码时,它会跳转到下一个 TextField。在一个空的 TextField 中输入 Backspace 时,它会清除前一个 TextField。
Backspace 是事情变得棘手的地方。清除前一个 TextField 可能会导致同一个 TextField 也把前一个清除。即使跟踪当前焦点的 TextField 也不起作用,导致同样的行为。我通过进行一个状态更新来实现,使其指示焦点前进或后退。
最终这个方案确实可行,但遇到了一个问题:输入一个包含双位数字的PIN码。输入类似 2009 的内容时,第二个 0 会被吞掉。焦点尚未移动到下一个 TextField。
我想要一个更可靠的方案。
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 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)
) {
ElevatedButton(
enabled = hasPinCode,
onClick = {
onNext(pinCode)
}
) {
Text("DONE")
}
}
}
}
@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() {
MaterialTheme {
Scaffold { innerPadding ->
Box(
modifier = Modifier
.padding(innerPadding)
) {
PinInputView(
onNext = {},
)
}
}
}
}
解决方案
我的解决方案是放弃单独的 TextField,改用一个接收输入但不可见的 BasicTextField。然后创建一个 Composable,按需要显示输入。
该方案是以下两点的结合:
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 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 = 40.dp)
.fillMaxSize()
.wrapContentSize(align = Alignment.BottomCenter)
) {
ElavatedButton(
enabled = hasPinCode,
onClick = {
onNext(pinFieldState.pinCode)
}
) {
Text("DONE")
}
}
}
}
@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() {
MaterialTheme {
Scaffold { innerPadding ->
Box(
modifier = Modifier.padding(innerPadding)
) {
PinInputView(
onNext = {}
)
}
}
}
}
站内所有文章版权归属LeftHeroAI导航站,无授权禁止任何主体转载、抄袭、复制内容,亦不得私自架设镜像站点。一经侵权,本站将通过法律途径追责。