You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在Jetpack Compose中实现首尾圆角的连续方形OTP输入框

在Jetpack Compose中实现首尾带圆角的连续方形OTP输入项

核心实现思路

通过Row布局多个自定义输入框,根据输入框的位置(第一个/最后一个/中间)设置不同的圆角样式,同时处理输入焦点自动切换、文本输入与删除逻辑,模拟原生OTP输入的交互体验。

完整代码实现

import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp

@Composable
fun RoundedOtpInput(
    otpLength: Int = 6,
    otpCharColor: Color = Color.Black,
    borderColor: Color = Color.Gray,
    focusedBorderColor: Color = Color.Blue,
    backgroundColor: Color = Color.White,
    cornerRadius: Int = 12
) {
    // 存储每个OTP框的文本内容
    val otpValues = remember { mutableStateListOf<TextFieldValue>().apply {
        repeat(otpLength) { add(TextFieldValue()) }
    } }
    // 每个输入框的焦点请求器
    val focusRequesters = remember { List(otpLength) { FocusRequester() } }
    // 当前焦点所在的输入框索引
    var currentFocusIndex by remember { androidx.compose.runtime.mutableStateOf(0) }

    Row(
        horizontalArrangement = Arrangement.Center,
        verticalAlignment = Alignment.CenterVertically
    ) {
        repeat(otpLength) { index ->
            val isFirst = index == 0
            val isLast = index == otpLength - 1
            // 根据位置设置圆角
            val shape = when {
                isFirst && isLast -> RoundedCornerShape(cornerRadius.dp)
                isFirst -> RoundedCornerShape(topStart = cornerRadius.dp, bottomStart = cornerRadius.dp)
                isLast -> RoundedCornerShape(topEnd = cornerRadius.dp, bottomEnd = cornerRadius.dp)
                else -> RoundedCornerShape(0.dp)
            }

            BasicTextField(
                value = otpValues[index],
                onValueChange = { newVal ->
                    otpValues[index] = newVal.copy(text = newVal.text.take(1))
                    // 输入字符后自动跳转到下一个输入框
                    if (newVal.text.isNotEmpty() && index < otpLength - 1) {
                        currentFocusIndex = index + 1
                        focusRequesters[index + 1].requestFocus()
                    }
                },
                keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
                keyboardActions = KeyboardActions(
                    onNext = {
                        if (index < otpLength - 1) {
                            currentFocusIndex = index + 1
                            focusRequesters[index + 1].requestFocus()
                        }
                    }
                ),
                modifier = Modifier
                    .size(48.dp)
                    .background(backgroundColor, shape)
                    .border(
                        width = 1.dp,
                        color = if (currentFocusIndex == index) focusedBorderColor else borderColor,
                        shape = shape
                    )
                    .focusRequester(focusRequesters[index])
                    .focusable(),
                decorationBox = { innerTextField ->
                    Row(
                        horizontalArrangement = Arrangement.Center,
                        verticalAlignment = Alignment.CenterVertically,
                        modifier = Modifier.size(48.dp)
                    ) {
                        if (otpValues[index].text.isEmpty()) {
                            Text(text = "", color = otpCharColor)
                        } else {
                            Text(text = otpValues[index].text, color = otpCharColor)
                        }
                    }
                    innerTextField()
                }
            )

            // 输入框之间的间隔,最后一个输入框后不需要
            if (index != otpLength - 1) {
                Spacer(modifier = Modifier.width(8.dp))
            }
        }
    }

    // 监听焦点变化,更新当前焦点索引
    LaunchedEffect(Unit) {
        focusRequesters.forEachIndexed { index, focusRequester ->
            snapshotFlow { focusRequester.focusState.isFocused }.collect { isFocused ->
                if (isFocused) {
                    currentFocusIndex = index
                }
            }
        }
    }

    // 初始时让第一个输入框获取焦点
    LaunchedEffect(Unit) {
        focusRequesters[0].requestFocus()
    }
}

// 使用示例
@Composable
fun OtpInputDemo() {
    RoundedOtpInput(
        otpLength = 6,
        focusedBorderColor = Color(0xFF2196F3),
        borderColor = Color(0xFFE0E0E0),
        cornerRadius = 12
    )
}

关键部分说明

  1. 圆角样式控制:通过判断输入框的索引是否为第一个或最后一个,动态设置RoundedCornerShape,实现首尾输入框的圆角效果,中间输入框保持直角。
  2. 焦点自动管理:使用FocusRequester控制每个输入框的焦点,输入字符后自动跳转到下一个输入框,可自行扩展删除逻辑,比如监听Back键实现上跳。
  3. 输入限制:通过take(1)确保每个输入框只能输入一个字符,符合OTP输入的需求。
  4. 自定义样式:提供了颜色、圆角大小、输入框尺寸等可配置参数,方便根据需求调整UI样式。

扩展优化建议

  • 添加删除逻辑:监听键盘Back键,当当前输入框为空时,自动跳转到上一个输入框并清空内容。
  • 输入完成回调:添加onOtpCompleted参数,当所有输入框都填满时触发回调,返回完整的OTP字符串。
  • 错误状态样式:支持设置错误状态下的边框颜色,方便在输入错误时提示用户。

内容的提问来源于stack exchange,提问作者Ye Htet Nyi

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.22 19:15:03