在 Android 移动应用开发中,创建一个多位数的一次性密码(OTP)输入框是一个常见的挑战。与使用单个 TextField 的传统方法不同,现代的做法是为每一位数字创建独立的组件。这能提供更好的视觉反馈和更专业的用户界面。通过 Kotlin 和 Jetpack Compose,我们可以构建一个既美观又高效的解决方案。
核心目标:设计独立的数字输入框
我们将为 OTP 输入框设计合适的用户界面,把每一位数字都视为独立的组件。核心逻辑是:当用户输入一位数字后,光标会自动移动到下一个输入框,并在用户按下退格键时能智能地回退。
为每一位数字创建输入框
每个输入框都需要一个可为空的数字、焦点请求器、键盘操作以及数字变更的回调。
@Composable
fun OTPInputField(
number: Int?, // 允许文本字段表示空状态或单个数字 (0-9)
focusRequester: FocusRequester, // 允许以编程方式从一个框移动到另一个框
onFocusChanged: (Boolean) -> Unit, // 报告此特定字段当前是否处于活动状态
onNumberChanged: (Int?) -> Unit, // 用户输入或删除数字时的主回调
onKeyboardBack: () -> Unit, // 当字段已为空时处理 `backspace` 键的事件
modifier: Modifier = Modifier
) {
var text by remember(number) {
mutableStateOf(
// 使用 TextFieldValue 而不是简单的 String 来控制光标
TextFieldValue(
text = number?.toString().orEmpty(), // 如果为 null 值,则使用空文本
selection = TextRange(
index = if (number != null) 1 else 0
) // 索引设为 `1` 确保光标始终位于数字之后
)
)
}
var isFocused by remember { // 初始化为 false
mutableStateOf(false)
}
Box(
modifier = modifier
.border(
width = 1.dp,
color = Green
)
.background(LightGrey),
contentAlignment = Alignment.Center
) {
BasicTextField(
value = text,
onValueChange = { newText ->
val newNumber = newText.text
if (newNumber.length <= 1 && newNumber.isDigitsOnly()) {
onNumberChanged(newNumber.toIntOrNull())
} // 这确保用户不能输入超过一个字符或输入字母/符号
},
cursorBrush = SolidColor(Green),
singleLine = true,
textStyle = TextStyle(
textAlign = TextAlign.Center,
fontWeight = FontWeight.Light,
fontSize = 36.sp,
color = Green
),
keyboardOptions = KeyboardOptions( // 键盘类型设为数字
keyboardType = KeyboardType.NumberPassword
),
modifier = Modifier
.padding(10.dp)
.focusRequester(focusRequester)
.onFocusChanged {
isFocused = it.isFocused
onFocusChanged(it.isFocused)
} // 它将外部的 focusRequester 链接到内部的文本字段,并更新本地的 isFocused 状态以处理视觉变化
.onKeyEvent { event ->// 如果用户按下退格键且框已为空,则触发 onKeyboardBack()。// 父组件通常会使用此事件将焦点移动到前一个框,// 允许用户快速纠正错误。
val didPressedDelete = event.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DEL
if (didPressedDelete && number == null) {
onKeyboardBack()
}
false
},
decorationBox = { innerBox ->
innerBox()
if (!isFocused && number == null) {
// 当字段为空且未被交互时,显示一个连字符 `-`。// 这为用户提供了一个视觉提示,表明该特定位置缺少数字。
Text(
text = "-",
textAlign = TextAlign.Center,
color = CodingGreen,
fontSize = 36.sp,
fontWeight = FontWeight.Light,
modifier = Modifier
.fillMaxSize()
.wrapContentSize()
)
}
}
)
}
}
OTP 状态管理
我们需要一个数据类来管理整个 OTP 输入的状态。
data class OtpState(
val code: List<Int?> = (1..4).map { null },
val focusedIndex: Int? = null,
val isValid: Boolean? = null
)
定义 OTP 操作
使用密封接口来定义所有可能的用户操作。
sealed interface OtpAction {
data class OnEnterNumber(val number: Int?, val index: Int) : OtpAction
data class OnChangeFieldFocused(val index: Int): OtpAction
data object OnKeyboardBack: OtpAction
}
构建 OTP ViewModel
ViewModel 负责处理状态变更和业务逻辑。
class OtpViewModel : ViewModel() {
private val _state = MutableStateFlow(OtpState())
val state = _state.asStateFlow()
fun onAction(action: OtpAction) {
when (action) {
is OtpAction.OnChangeFieldFocused -> {
_state.update {
it.copy(
focusedIndex = action.index
)
}
}
is OtpAction.OnEnterNumber -> {
enterNumber(action.number, action.index)
}
OtpAction.OnKeyboardBack -> {
val previousIndex = getPreviousFocusedIndex(state.value.focusedIndex)
_state.update {
it.copy(
code = it.code.mapIndexed { index, number ->if (index == previousIndex) {
null
} else {
number
}
},
focusedIndex = previousIndex
)
}
}
}
}
// ...
}
焦点回退逻辑
当用户在当前空输入框按退格键时,我们需要将焦点移动到前一个输入框。
private fun getPreviousFocusedIndex(currentIndex: Int?): Int? {
return currentIndex?.minus(1)?.coerceAtLeast(0)
}
- 如果当前索引为
null,则返回 null。
- 递减
minus(1),返回到前一个索引(前一个输入框)。
coerceAtLeast(0) 确保结果数字永远不会低于零。
焦点前移逻辑
当用户输入一个数字后,焦点应该智能地移动到下一个需要输入的位置。
private fun getNextFocusedTextFieldIndex(
currentCode: List<Int?>,
currentFocusedIndex: Int?
): Int? {
if (currentFocusedIndex == null) {
return null
}
if (currentFocusedIndex == 3) {
return currentFocusedIndex
}
return getFirstEmptyFieldIndexAfterFocusedIndex(
code = currentCode,
currentFocusedIndex = currentFocusedIndex
)
}
getNextFocusedTextFieldIndex 的目的是提供自动的焦点转移。它确保当用户输入“1–2–3–4”时,光标会自动从一个框“跳”到另一个框,而无需用户手动点击每一个框。
private fun getFirstEmptyFieldIndexAfterFocusedIndex(
code: List<Int?>,
currentFocusedIndex: Int
): Int {
code.forEachIndexed { index, number ->if (index <= currentFocusedIndex) {
return@forEachIndexed
}
if (number == null) {
return index
}
}
return currentFocusedIndex
}
这个方法比简单的 index + 1 逻辑更智能。即使该框已有数字,简单的逻辑也会移动到下一个框。而当前这个方法尊重现有数据,只将用户移动到他们实际需要提供输入的位置。
核心:处理数字输入
这是整个状态更新的核心函数。
private fun enterNumber(number: Int?, index: Int) {
val newCode = state.value.code.mapIndexed { currentIndex, currentNumber ->if (currentIndex == index) {
number
} else {
currentNumber
}
}
val wasNumberRemoved = number == null
_state.update {
it.copy(
code = newCode,
focusedIndex = if (wasNumberRemoved || it.code.getOrNull(index) != null) {
it.focusedIndex
} else {
getNextFocusedTextFieldIndex(
currentCode = it.code,
currentFocusedIndex = it.focusedIndex
)
},
isValid = if (newCode.none { it == null }) {
newCode.joinToString("") == VALID_OTP_CODE
} else null
)
}
}
让我们通过两个场景来理解这个函数:
- 用户在第二个框中输入数字时:
- 更新:代码列表从
[1, null, null, null] 变为 [1, 2, null, null]。
- 移动:由于新数字被添加到一个空槽中,
focusedIndex 跳转到第三个框(索引 2)。
- 验证:系统看到代码不完整(
none { it == null } 为 false),因此 isValid 保持为 null。
- 用户输入最后一位数字时:
- 更新:代码列表变为
[1, 2, 3, 4]。
- 移动:到达末尾。
- 验证:它看到代码已满,将“1234”与硬编码的有效代码进行比较,并设置
isValid = true。
组合 OTP 输入屏幕
这是将所有独立输入框组合在一起的 UI 层。
@Composable
fun OtpScreen(
state: OtpState,
focusRequesters: List<FocusRequester>,
onAction: (OtpAction) -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Row(
modifier = Modifier
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally)
) {
state.code.forEachIndexed { index, number ->
OTPInputField(
number = number,
focusRequester = focusRequesters[index],
onFocusChanged = { isFocused ->if (isFocused){
onAction(OtpAction.OnChangeFieldFocused(index))
}
},
onNumberChanged = { newNumber ->
onAction(OtpAction.OnEnterNumber(newNumber, index))
},
onKeyboardBack = {
onAction(OtpAction.OnKeyboardBack)
},
modifier = Modifier
.weight(1f)
.aspectRatio(1f)
)
}
}
// 此文本显示 OTP 是无效还是有效
state.isValid?.let { isValid ->
Text(
text = if (isValid) "OTP 有效!" else "OTP 无效!",
color = if (isValid) CodingGreen else Color.Red,
fontSize = 16.sp
)
}
}
}
整合所有部分:主屏幕
最后,我们在主屏幕中整合状态、焦点管理和副作用。
@Composable
fun OtpMainScreen() {
Scaffold(
modifier = Modifier
.fillMaxSize(),
containerColor = CodingGrey
) { paddingValues ->
val viewModel = viewModel<OtpViewModel>()
val state by viewModel.state.collectAsStateWithLifecycle()
val focusRequesters = remember {
List(4) { FocusRequester() } // (1..4).map { FocusRequester() }
}
val focusManager = LocalFocusManager.current
val keyboardManager = LocalSoftwareKeyboardController.current
LaunchedEffect(state.focusedIndex) {
state.focusedIndex?.let { index ->
focusRequesters.getOrNull(index)?.requestFocus()
}
}
LaunchedEffect(state.code, keyboardManager) {
val allNumbersEntered = state.code.none { it == null }
if (allNumbersEntered) {
focusRequesters.forEach { it.freeFocus() }
focusManager.clearFocus()
keyboardManager?.hide()
}
}
OtpScreen(
state = state,
focusRequesters = focusRequesters,
onAction = { action ->
when (action) {
is OtpAction.OnEnterNumber -> {
if (action.number != null) {
focusRequesters[action.index].freeFocus()
}
}
else -> Unit
}
viewModel.onAction(action)
},
modifier = Modifier
.padding(paddingValues)
.consumeWindowInsets(paddingValues)
)
}
}
关键副作用解析
-
自动焦点转移:
LaunchedEffect(state.focusedIndex) {
state.focusedIndex?.let { index ->
focusRequesters.getOrNull(index)?.requestFocus()
}
}
- 每当
state.focusedIndex 发生变化时(例如,输入后从 0 变为 1,或退格后从 1 变为 0),它就会运行。
- 它会查看你的
focusRequesters 列表(附加到每个 OTPInputField 的“钩子”),并在状态提供的特定索引上调用 .requestFocus()。
- 这就是实际将闪烁的光标从一个框移动到另一个框而无需用户点击屏幕的原因。
-
完成时自动收起键盘:
LaunchedEffect(state.code, keyboardManager) {
val allNumbersEntered = state.code.none { it == null }
if (allNumbersEntered) {
focusRequesters.forEach { it.freeFocus() }
focusManager.clearFocus()
keyboardManager?.hide()
}
}
- 每当代码数组中的任何数字发生变化时,它就会运行。
- 它检查是否“没有”为
null 的项。如果每个框都有数字,则意味着 OTP 已完成。
clearFocus():闪烁的光标从最后一个框中消失,向用户发出输入完成的信号。
keyboardManager?.hide():数字键盘自动滑下。这是一个关键的 UX 模式;它允许用户清楚地看到“验证”按钮或“有效/无效”文本,而不会被键盘挡住屏幕的下半部分。
总结与展望
此实现展示了一种构建 OTP 输入系统的稳健且专业的方法。通过将每个数字模块化为无状态可组合项、实现无缝的焦点转移和精确的键盘控制,我们为身份验证流程创建了一个可扩展的基础,将干净的状态管理与响应式 UI 模式相结合。
这个自定义组件有效地平衡了视觉反馈(例如占位符连字符)与严格的输入验证,从而提供了一个既安全又直观的用户界面。无论你是构建简单的登录流程还是需要高度安全的交易验证屏幕,这里提供的架构和代码都可以作为坚实的基础,供你根据具体需求进行定制和扩展。
希望这篇在 云栈社区 分享的教程能帮助你更好地理解和应用 Compose 构建现代 Android UI。如果你有更好的实现思路或优化建议,也欢迎交流探讨。
原文链接:https://kabi20.medium.com/one-time-password-input-field-in-kotlin-using-compose-34f094f40ce8