支付输入框组件设计:自定义 OutlinedTextField、键盘切换、金额格式化与校验
支付输入框是支付 UI 中与用户交互最频繁的组件,直接承载金额、卡号、CVV 等敏感信息。Compose 原生的 OutlinedTextField 无法直接满足支付场景的格式化、校验与键盘切换需求,需要通过组合自定义视觉变换(VisualTransformation)、输入过滤器(InputFilter)、键盘选项(KeyboardOptions)及校验逻辑来构建安全、流畅的支付文本输入体验。
1 核心原理
- 格式化层:通过
VisualTransformation截取原始文本流,在显示时插入千分位分隔符、限制小数位数(例如金额保留两位小数),原始值不改变。 - 校验层:在
onValueChange中执行实时校验,判断是否为有效数字、是否超过上限、是否包含非法字符,并驱动错误状态。 - 键盘切换:使用
KeyboardOptions(keyboardType = KeyboardType.Number)弹出纯数字键盘,必要时根据输入场景动态切换(如卡号输入时禁用空格自动补全)。 - 安全兜底:对输入框内明文进行遮罩(如输入卡号时显示后四位),需额外实现
VisualTransformation。
2 实现流程
- 创建自定义 Composable 函数,接收
value: String、onValueChange: (String) -> Unit、isError: Boolean等参数。 - 包装 OutlinedTextField,替换其
visualTransformation为金额格式化器。 - 在 onValueChange 回调中,先过滤非法字符,再逐位应用格式,同时触发校验。
- 校验逻辑:判断字符串是否为空、是否能解析为 Double、小数位数是否 ≤ 2、数值是否在 0.01~999999.99 范围内。
- 样式定制:通过
TextFieldDefaults.outlinedTextFieldColors()设置焦点边框色、错误边框色、光标色、背景色。 - 键盘切换:在
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 组件内部数据流
10 部署与组件化建议
- 将
AmountInputField封装成独立模块,暴露@Composable函数和校验结果isValid: Boolean。 - 配合主题管理,通过
MaterialTheme动态更换边框、标签颜色。 - 在支付表单页面中,使用
LaunchedEffect监听所有输入框的校验状态,控制提交按钮的启用与禁用。
核心要点:
- 格式化用
VisualTransformation,不要污染原始数据。 - 键盘类型用
KeyboardType.Number并关闭自动纠错。 - 校验在
onValueChange内执行,错误状态驱动isError。 - 光标位置是格式化的难点,优先使用
TextFieldValue管理。