支付输入框组件设计:自定义 OutlinedTextField、键盘切换、金额格式化与校验

支付输入框是支付 UI 中与用户交互最频繁的组件,直接承载金额、卡号、CVV 等敏感信息。Compose 原生的 OutlinedTextField 无法直接满足支付场景的格式化、校验与键盘切换需求,需要通过组合自定义视觉变换(VisualTransformation)、输入过滤器(InputFilter)、键盘选项(KeyboardOptions)及校验逻辑来构建安全、流畅的支付文本输入体验。

1 核心原理

  • 格式化层:通过 VisualTransformation 截取原始文本流,在显示时插入千分位分隔符、限制小数位数(例如金额保留两位小数),原始值不改变。
  • 校验层:在 onValueChange 中执行实时校验,判断是否为有效数字、是否超过上限、是否包含非法字符,并驱动错误状态。
  • 键盘切换:使用 KeyboardOptions(keyboardType = KeyboardType.Number) 弹出纯数字键盘,必要时根据输入场景动态切换(如卡号输入时禁用空格自动补全)。
  • 安全兜底:对输入框内明文进行遮罩(如输入卡号时显示后四位),需额外实现 VisualTransformation。

2 实现流程

  1. 创建自定义 Composable 函数,接收 value: String、onValueChange: (String) -> Unit、isError: Boolean 等参数。
  2. 包装 OutlinedTextField,替换其 visualTransformation 为金额格式化器。
  3. 在 onValueChange 回调中,先过滤非法字符,再逐位应用格式,同时触发校验。
  4. 校验逻辑:判断字符串是否为空、是否能解析为 Double、小数位数是否 ≤ 2、数值是否在 0.01~999999.99 范围内。
  5. 样式定制:通过 TextFieldDefaults.outlinedTextFieldColors() 设置焦点边框色、错误边框色、光标色、背景色。
  6. 键盘切换:在 KeyboardOptions 中固定 KeyboardType.Number,并关闭自动纠错与建议。

3 核心 API 与配置

API / 组件用途关键属性
OutlinedTextField外边框文本输入框visualTransformation, isError, keyboardOptions, colors
VisualTransformation格式化显示文本,不影响原始值filter(text: AnnotatedString)
KeyboardOptions配置键盘类型及行为keyboardType, imeAction, autoCorrect
TextInputFilter过滤输入字符,例如只允许数字和小数点filter(source, start, end, dest, dstart, dend)
Modifier手势、焦点、边距等扩展.onFocusChanged {}, .padding()

4 代码实现:金额输入框组件

@Composable
fun AmountInputField(
    value: String,
    onValueChange: (String) -> Unit,
    modifier: Modifier = Modifier,
    isError: Boolean = false,
    errorMessage: String? = null
) {
    val amountTransformation = AmountVisualTransformation()
    var showError by remember { mutableStateOf(false) }

    OutlinedTextField(
        value = value,
        onValueChange = { raw ->
            // 1. 过滤非数字、小数点(只允许一个小数点,最多两位小数)
            val filtered = raw
                .filter { c -> c.isDigit() || c == '.' }
                .let { limitDecimalPlaces(it, 2) }
            // 2. 实时校验
            showError = filtered.isNotEmpty() && !isValidAmount(filtered)
            onValueChange(filtered)
        },
        visualTransformation = amountTransformation,
        keyboardOptions = KeyboardOptions(
            keyboardType = KeyboardType.Number,
            autoCorrect = false
        ),
        isError = isError || showError,
        supportingText = if (showError) {
            { Text(errorMessage ?: "无效金额", color = MaterialTheme.colorScheme.error) }
        } else null,
        label = { Text("金额(元)") },
        singleLine = true,
        colors = OutlinedTextFieldDefaults.colors(
            focusedBorderColor = MaterialTheme.colorScheme.primary,
            unfocusedBorderColor = MaterialTheme.colorScheme.outline,
            errorBorderColor = MaterialTheme.colorScheme.error,
            cursorColor = MaterialTheme.colorScheme.primary
        ),
        modifier = modifier.fillMaxWidth()
    )
}

private fun limitDecimalPlaces(input: String, maxDec: Int): String {
    if (!input.contains('.')) return input
    val parts = input.split(".")
    if (parts.size > 2) return parts[0] + "." + parts[1]
    return if (parts[1].length > maxDec) {
        parts[0] + "." + parts[1].take(maxDec)
    } else input
}

private fun isValidAmount(amount: String): Boolean {
    if (amount.isEmpty()) return false
    val value = amount.toDoubleOrNull() ?: return false
    return value in 0.01..999999.99
}

5 金额格式化 VisualTransformation

class AmountVisualTransformation : VisualTransformation {
    override fun filter(text: AnnotatedString): TransformedText {
        val original = text.text
        // 去除千分位,避免重复格式化
        val clean = original.replace(",", "")
        if (clean.isEmpty()) return TransformedText(text, OffsetMapping.Identity)

        val parts = clean.split(".")
        val integerPart = parts[0]
        val decimalPart = if (parts.size > 1) "." + parts[1] else ""

        // 反转整数部分并添加千分位
        val formattedInteger = integerPart.reversed().chunked(3).joinToString(",").reversed()
        val formatted = formattedInteger + decimalPart

        // 计算偏移映射:字符位置的转换
        val offsetMapping = object : OffsetMapping {
            override fun originalToTransformed(offset: Int): Int {
                // 简化映射:每个原始字符对应格式化字符串中相同位置
                return offset + (if (offset > 0 && offset % 3 == 0) 1 else 0)
            }
            override fun transformedToOriginal(offset: Int): Int {
                // 反向映射(实现略复杂,此处只做示例)
                return offset - (offset / 4)  // 粗略估算
            }
        }
        return TransformedText(AnnotatedString(formatted), offsetMapping)
    }
}
提示:上述 offsetMapping 为简化实现,生产环境中建议使用 OffsetMapping.Identity 并配合 onValueChange 手动维护光标位置,避免光标跳动。

6 键盘切换与输入法优化

  • 对于纯金额输入,固定 KeyboardType.Number 并设置 autoCorrect = false,禁止系统自动更正。
  • 若同一页面同时有金额、备注输入框,可在 onFocusChanged 中动态切换 keyboardOptions(需使用 key 参数触发重组)。
  • 针对 Android 输入法行为差异,建议在自定义组件中添加 imeAction = ImeAction.Done 以支持“完成”按键回收键盘。

7 调试与验证

  • 打印原始值和显示值:在 onValueChange 内使用 Log.d("AmountField", "raw=$raw, formatted=${amountTransformation.filter...}")。
  • 校验边界:输入 0.001 → 应被截断为 0.00;输入 999999.99 → 应允许;输入 1000000.00 → 应报错。
  • 键盘测试:连接实体设备或模拟器,切换数字键盘和字母键盘,观察输入能否正常跳转。
常见错误:
  • 千分位格式化导致 onValueChange 回传字符串包含逗号,破坏数值解析 → 解决方法:在 onValueChange 中先 replace(",", "") 再存。
  • 光标位置错误:用户输入中间插入字符时光标跳到最后 → 使用 TextFieldValue 与 TextInputService 手动控制。
  • 键盘类型未生效:在模拟器中某些中文输入法忽略 KeyboardType → 建议在真机上测试。

8 性能与稳定性优化

  • 避免重组:将 AmountVisualTransformation 实例用 remember 缓存,不要每次重组新建。
  • 延迟校验:高频输入场景(如快速连按数字)可使用 debounce(如 snapshotFlow + debounce(300))校验,减少无效计算。
  • 长金额分割:超过 15 位的数字不显示千分位,改用可滚动输入框防止溢出。
  • 内存泄漏:避免在 VisualTransformation 内持有大型正则或递归函数。

9 组件内部数据流

支付输入框组件数据流 用户输入 键盘切换 格式化器 实时校验器 OutlinedTextField 用户输入经键盘切换 → 格式化 → 校验 → 最终在 OutlinedTextField 中显示 过滤非法字符 → 限制小数位数 → 生成千分位显示 → 保持光标位置

10 部署与组件化建议

  • 将 AmountInputField 封装成独立模块,暴露 @Composable 函数和校验结果 isValid: Boolean。
  • 配合主题管理,通过 MaterialTheme 动态更换边框、标签颜色。
  • 在支付表单页面中,使用 LaunchedEffect 监听所有输入框的校验状态,控制提交按钮的启用与禁用。
核心要点:
  • 格式化用 VisualTransformation,不要污染原始数据。
  • 键盘类型用 KeyboardType.Number 并关闭自动纠错。
  • 校验在 onValueChange 内执行,错误状态驱动 isError。
  • 光标位置是格式化的难点,优先使用 TextFieldValue 管理。