做鸿蒙应用的时候,我遇到一个特别常见的需求:验证码输入框,要求每个格子只显示一个字符,下面带一条下划线。这种交互在登录、注册、支付确认里几乎天天见,但系统自带的 TextInput 默认是一个连续横条,视觉上完全不满足要求。我最早也想偷懒,用普通输入框加样式糊弄过去,结果视觉稿一比对就露馅了。后来我基于 ArkUI 从零实现了一个“单格单字符 + 下划线”的输入框,既能输入手机号,也能当短信验证码框用,实测稳定,今天把完整思路和代码拆出来。
这篇文章适合正在用 ArkUI 做鸿蒙应用、尤其是做登录注册模块的开发者,也适合刚接触声明式 UI 的小伙伴参考。我会把组件设计、状态管理、输入法处理这些关键点都讲透,最后还会列出我踩过的几个坑,方便你直接抄作业。
1. 为什么“单格输入框”值得单独做一个组件
1.1 这类输入框到底解决了什么问题
先说产品层面的原因。验证码输入框用“单格单字符 + 下划线”的视觉,本质上是为了降低用户的理解成本。用户看到六个空格,自然就知道“我要填六位数字”,不需要看提示文案。手机号输入框用分段下划线,也能让用户一眼看出“11 位号码该分三段念”,比如 138 1234 5678,这比挤在一根长线里更容易核对。
从技术层面说,如果不单独做组件,直接用 TextInput 凑合,会有一堆体验问题。比如系统输入框自带边框、光标闪烁、底部横线又不是我们想要的分段样式;如果堆六个 TextInput 又会出现焦点跳转、删除越界、粘贴分裂等一系列麻烦。所以我的结论很明确:在 ArkUI 里实现一个自定义单格输入框,不是炫技,是登录体验的基本功。
1.2 三个实现方向,我为什么选透明覆盖层
我调研过三种方案。
第一种:用六个独立的 TextInput 拼一排。每个格子一个输入框,监听输入后自动把焦点移到下一个,删空时移回上一个。听起来直观,实际写起来很痛苦。用户粘贴一串验证码时,六个框都要处理;焦点在中间位置时,退格键行为很容易乱;输入法弹起时还会出现多个输入框抢占焦点的问题。性能上也不划算,六个原生输入框实例只为一个六位数服务。
第二种:只用一个 TextInput,通过自定义背景和左内边距,让输入内容看起来像是“均匀分布在格子中”。这种方案最简单,但限制也很大:文字长度、字间距、格位数一变,对齐就要重新调;下划线想做成“当前格高亮”这种状态,基本做不到;手机号三段分隔更是难搞。
第三种:用透明 TextInput 做输入层,用 Row + Column 自定义格子做展示层,两层叠在同一个 Stack 里。这就是我最后选用的方案。底层代码我稍后给全,先讲清楚核心逻辑:系统键盘、光标移动、文本粘贴、输入法联想这些复杂交互,全部交给原生 TextInput 处理;我们只负责把真实输入框隐藏,然后用自绘的格子把字符重新画出来。
这个方法的最大好处,是状态模型极其简单。组件内部只有一个字符串 code,长度是多少就显示多少位,删除、插入、粘贴全部由系统输入层完成,再通过 onChange 同步给我们。不需要手动管焦点跳转,也不需要处理跨格子删除。
1.3 透明层方案的一点提醒
有同学可能一看到“透明 TextInput”就联想到 opacity(0)。我在最开始也这么干,把整个 TextInput 设成 opacity(0),结果在个别设备上出现点击区域偶尔失效的问题。后来我改用另一种“伪隐藏”:不调 opacity,而是把 TextInput 的文字颜色、光标颜色、背景颜色全部设成透明,再关掉边框。这样组件仍然真实参与布局和触摸,只是用户看不见。实测下来点击稳定,键盘唤起也正常。
这也是我特别想强调的一点:视觉隐藏和交互隐藏是两回事。做组件时,能用属性把视觉去掉,就不要用 opacity 把整个组件“半移除”。
2. 组件整体设计与状态拆解
2.1 组件需要的数据和回调
自定义输入框要什么状态?只需要一个字符串。不是数组、不是对象,就是一个字符串 code。这六个格子只是 code 的可视化投影,格子本身没有状态,这是理解这个组件最关键的地方。
对外我们要暴露三个能力:
- maxLength:最多几位,验证码一般 6 位,手机号 11 位。
- onChange:每输入一位或者删除一位都通知外部。
- onCompleted:填满所有格子时通知外部,方便外部自动发起校验,不用用户再点按钮。
如果你还有“倒计时重新发送验证码”“校验失败清空输入框”这些需求,父组件可以直接通过修改传给子组件的状态变量,或者调用子组件的方法来重置 code,这个后面会展开。
2.2 显示层的层级结构
显示层用 Stack 包住两个子层:
- 底层是一个 Row,Row 里用 ForEach 渲染 maxLength 个格子,每个格子是 Column。
- 顶层是透明 TextInput,覆盖整个 Row。
这里有一个小细节:Stack 里后添加的组件会盖在前面的组件上,所以 TextInput 必须放在 ForEach 后面,这样它才是顶层。顶层的透明输入框负责接收点击和键盘输入,底层格子只做展示。
每个格子内部其实只有两个元素:一个 Text 显示当前位字符,一个 Divider 显示下划线。Divider 的粗细和颜色可以根据输入状态变化:已输入的格子用高亮色,当前等待输入的位置用稍深的颜色,未输入的空位用浅灰色。这样用户能自然感知到“我现在该填第几位”。
2.3 一个容易被忽略的问题:光标永远要在最后
单个输入框虽然能点击任意位置,但验证码这种场景应该“只能从前往后填,只能从后往前删”。如果用户点了一下中间,把那一位删掉,整个逻辑就会乱套。更常见的问题是:用户已经输入了三位,点击格子中间,光标跳到第二位,再输入就会插入到中间,格子显示会变得很奇怪。
解决办法是强制光标跟随内容末尾。在 onChange 回调里,每次更新 code 后,调用输入框 controller 的 caretPosition 方法,把光标移动到 code.length。同样在 onFocus 和 onClick 里也把光标拉到最后。这也符合验证码输入框的交互直觉:你只能追加或从末尾退格,不能中间篡改。
我见过不少初版实现没有处理这个问题,导致用户用系统输入法候选词或点击中间位置后,组件显示和真实内容对不上。这个问题必须在组件设计阶段就考虑到,否则后面排查会花很长时间。
3. 核心代码实现与关键细节
3.1 基础组件代码:六位验证码输入框
下面是我整理后的简化版完整代码,可以直接放到你的自定义组件文件里使用。为了阅读方便,我用固定宽度 40vp 的格子做演示,实际项目里可以改成百分比或根据屏幕宽度计算。
@Component export struct SingleCharInput { @Prop maxLength: number = 6 @State code: string = '' private inputController: TextInputController = new TextInputController() @Event onChange: (value: string) => void = () => {} @Event onCompleted: (value: string) => void = () => {} build() { Stack({ alignContent: Alignment.Center }) { Row() { ForEach(this.getIndexArray(), (index: number) => { this.ItemCell(index) }, (index: number) => 'cell_' + index.toString()) } .width('100%') .justifyContent(FlexAlign.Center) TextInput({ text: this.code, controller: this.inputController }) .width('100%') .height(64) .backgroundColor(Color.Transparent) .fontColor(Color.Transparent) .caretColor(Color.Transparent) .type(InputType.Number) .maxLength(this.maxLength) .onChange((value: string) => { this.updateCode(value) }) .onFocus(() => { this.inputController.caretPosition(this.code.length) }) .onClick(() => { this.inputController.caretPosition(this.code.length) }) .onSubmit(() => { if (this.code.length === this.maxLength) { this.onCompleted(this.code) } }) } .width('100%') .height(64) } @Builder ItemCell(index: number) { Column({ space: 8 }) { Text(this.getChar(index)) .fontSize(22) .fontWeight(FontWeight.Bold) .fontColor(this.code.length === index ? '#1A1A1A' : '#8A8A8A') Divider() .width('100%') .strokeWidth(this.code.length === index ? 2 : 1) .color(this.code.length > index ? '#0A59F7' : '#D8D8D8') } .width(40) .margin({ left: 6, right: 6 }) } private getIndexArray(): number[] { const arr: number[] = [] for (let i = 0; i < this.maxLength; i++) { arr.push(i) } return arr } private getChar(index: number): string { if (index < this.code.length) { return this.code.substring(index, index + 1) } return '' } private updateCode(value: string) { let newCode = value.replace(/\D/g, '') if (newCode.length > this.maxLength) { newCode = newCode.substring(0, this.maxLength) } if (newCode !== this.code) { this.code = newCode this.onChange(this.code) if (this.code.length === this.maxLength) { this.onCompleted(this.code) } } this.inputController.caretPosition(this.code.length) } }这段代码里有一个轻微调整:我把焦点请求交给 TextInput 自己处理,因为透明输入层覆盖了整个输入区域,点击格子时它本身就是命中目标。你不需要在外层再包一层 onClick,否则可能触发事件冒泡,导致焦点请求重复执行。我在第一版里踩过这个坑,后来把外层 onClick 去掉,交互反而更干净。
3.2 使用组件的方式
父组件里使用起来很简洁:
@Entry @Component struct Index { @State verifyCode: string = '' build() { Column({ space: 20 }) { SingleCharInput({ maxLength: 6, onChange: (value: string) => { this.verifyCode = value }, onCompleted: (value: string) => { // 这里可以发起验证码校验 console.info('complete code: ' + value) } }) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) } }3.3 输入法类型、过滤和最大长度
你可能注意到,我在 TextInput 上设置了type(InputType.Number),又在 onChange 里做了replace(/\D/g, '')。为什么两层都做?
第一层是为了让软键盘直接弹纯数字键盘,减少用户切换键盘的麻烦。第二层是为了兜底。鸿蒙不同版本、不同输入法下,InputType.Number不一定能百分百过滤掉全角数字、空格或者特殊符号。而且用户从剪贴板粘贴内容时,可能带进来任意文本。所以 onChange 里必须再做一次正则过滤,保证进入 code 的永远是数字。
同时还要设置maxLength,并且我在 updateCode 里又做了一次截断。双保险的好处是,即使某些输入法绕过 maxLength 的限制,我们的状态也不会越界。
3.4 什么时候触发自动提交
验证码组件的常见体验是:输满六位后自动校验,不需要用户再点确认按钮。我在 updateCode 里判断了code.length === maxLength,满足就调用onCompleted。但这里有个细节要提醒:onCompleted 不能写在 onChange 外面,因为 onChange 拿到的 value 可能是没过滤前的字符串,长度判断不可靠。必须在过滤、截断之后再判断。
在 TextInput 的 onSubmit 里我也调了一次回调。这个对应的是键盘右下角“完成”按钮,属于主动提交路径,适合手机号这类场景。
3.5 手机号 3-4-4 分段变体怎么做
标题里提到了手机号,手机号输入框和验证码框稍微不太一样:它不是严格“单格”,而是分段下划线,中间要有间隙。你可以把 maxLength 设成 11,然后在显示层做两件事。
第一,格子数量仍然用 11 个,但在 index 为 2 和 6 的格子后面增加更大的 margin,从视觉上分成三段:3 位、4 位、4 位。第二,底部分隔线可以分段设置,比如每段的下划线使用一组 Divider 而不是全部连在一起。这个变体我并没有单独写一个新的组件,而是给格子渲染函数传入了一个segmentGap参数,让间距可配置。
如果你想在输入过程中自动显示空格,比如输入到第 3 位后自动加一个空格,那就不要用字母间距,直接把父组件的 code 透传给子组件,在显示层对 code 做格式化。我这里更推荐“分段间隙”方案,因为最终校验时你拿到的还是 11 位纯数字,不用额外去空格。
4. 透明输入层方案的实际避坑记录
4.1 点击格子没反应,焦点取不到
这个坑我前面提过,最初用 opacity(0) 时遇到过一次。后来改成透明颜色方案后,基本稳定。如果你在真机上发现点击区域反应不灵敏,可以检查两件事。
第一,TextInput 是否真的覆盖到了整个格子区域。Stack 默认子组件会根据内容自适应大小,所以我给 TextInput 显式设置了 width('100%') 和 height(64),保证它把整个 Row 包住。
第二,点击事件是否被格子里的 Column 挡住了。因为 TextInput 是后添加的,理论上在最上层。但如果你的代码把 TextInput 写在 Row 前面,它就会被格子盖住,点击命中在格子上而不是输入框上。这是层级顺序问题,解决方案很简单:把 TextInput 放在最后。
如果还是不行,可以给 TextInput 设置hitTestBehavior(HitTestMode.Block),强制让它参与命中测试。这个属性不是所有场景都需要,但当我遇到第三方输入法异常时,加上它确实能解决一部分问题。
4.2 粘贴验证码时长度和格式乱掉
用户从短信里复制验证码,长按输入框粘贴,这是最常见的操作。系统粘贴走的是 TextInput 默认逻辑,理论上会触发 onChange。但我在测试中发现两个问题:
一是粘贴内容含空格或换行,比如短信文案是“验证码 123456,十分钟内有效”,用户不小心复制了整段文字。这时 onChange 拿到的 value 会是一整段字符串,我们的正则过滤可以把非数字全部丢掉,最后只剩下 123456,这个方案没问题。
二是粘贴超过 maxLength 的内容,比如用户复制了 8 位数字。系统 TextInput 的 maxLength 可能会截断,也可能不会,所以我的 updateCode 里又做了一次 subString 截断。如果你只在属性上写 maxLength,不在逻辑里截断,在某些输入法上还是有概率越界。
处理完粘贴后还有一个细节:因为透明输入层不显示内容,用户粘贴时看不到文字,会觉得自己没粘贴成功。所以建议在 onCompleted 触发后,给一个轻提示或自动跳转,而不是干等着。我在实际项目中还做过一个优化:当识别到 code 长度等于 maxLength 且全为数字时,自动收起键盘,避免校验成功后键盘还挡着页面。
4.3 输入法弹起遮挡输入框
透明 TextInput 同样会唤起输入法,所以页面避让问题一个都逃不掉。如果你的输入框位于页面中下方,键盘弹起来后可能把它盖住。这时不要只盯着组件本身,要把页面整体布局考虑进去。
鸿蒙 ArkUI 页面默认对键盘有一定的避让处理,但如果你用了自定义 Bottom 容器、全屏 Stack 或者 Scroll 嵌套,行为可能会有差异。我的建议是:登录类页面尽量把输入框放在页面上半部分,或者用 Scroll 包住整个表单,保证键盘弹起后输入框仍然可见。
我在一个项目中把输入框放在 Button 上方,键盘弹起后整个按钮被盖住,用户填完验证码找不到提交按钮。后来我把页面改成 Column + Scroll,并给 Scroll 设置了足够的 bottom padding,问题就解决了。
4.4 下划线高亮和当前输入位的判断
验证码框的下划线不只是装饰,它承担了“当前输入位”的指示作用。我的实现里用code.length === index判断当前位,同时高亮下划线。这个判断在正常“只能从末尾输入”的前提下是成立的。
但有一种边缘情况:如果用户通过输入法候选词直接替换了整个字符串,系统可能会把 text 瞬间变成多个字符,我们的 code.length 会突然变大,此时高亮的“当前位”可能瞬间跳到下一个空格,视觉上会闪一下。这个问题可以通过 updateCode 里的“先强制定住光标、再更新 code”顺序来缓解。但如果你对动画要求很高,建议在 updateCode 中先用 emit 关闭输入联动,更新完再恢复。
4.5 ForEach 的 key 生成规则
我在代码里给每个格子生成的 key 是'cell_' + index.toString()。为什么不用 index?因为 ForEach 的 key 会影响组件复用。如果 maxLength 是固定 6 位,index 当 key 问题不大;但如果你复用组件时把 maxLength 从 6 改成 11,index 相同的格子会被错误复用,可能出现旧状态残留。加一个字符串前缀会安全很多。
另外,不要把 getChar 的返回值当成 key 的一部分。格子内容是要实时刷新的,如果 key 跟 content 绑定,每次输入都会导致整个 ForEach 重建,反而浪费性能。
5. 组件扩展与后续优化方向
5.1 支持“校验失败重置”和“倒计时联动”
实际使用中,父组件经常需要在验证失败后清空输入框并重新聚焦。我在子组件里额外加了一个控制方法,用来重置 code:
@Exported public resetCode(): void { this.code = '' this.inputController.caretPosition(0) }在 ArkUI 中,如果你需要使用子组件方法,可以通过@Exported导出,或者让父组件通过一个布尔状态变量来触发重置。我推荐第二种更符合声明式思维:父组件把resetFlag传给子组件,子组件用@Watch监听这个字段,重置后再把标志位还原。
倒计时联动比较简单,它和输入框本身关系不大,我建议放到父页面去管理。输入框只负责输出数字,不要让组件内部持有“重新发送验证码”这种业务状态,否则组件复用性会大大降低。
5.2 动效和状态提示
默认的下划线颜色变化很直接,但缺少过渡。如果你希望产品体验更细腻,可以给 Divider 的颜色变化加一个隐式动画,比如.animation({ duration: 150, curve: Curve.EaseOut })。这样用户输入时,下划线会有轻微的渐变效果,视觉上更柔和。
还有一个细节是格子字符的入场动画。可以给 Text 加一个轻微的 scale 动画:新字符出现时,从 0.8 放大到 1.0。这样用户能感知到“这次输入成功了”。对于高频输入场景,动画时间不要超过 150ms,否则输入快的时候会显得卡顿。
5.3 无障碍与字体大小适配
透明输入层方案有一个好处:底层格子里的 Text 是真正的文本节点,无障碍服务可以读到字符内容。但我建议给整个容器再设置一个无障碍文本,把完整 code 朗读出来,因为读屏用户逐格读会很累。
在字体缩放模式下,固定 40vp 的格子可能放不下大字体。我后来把格子宽度改为根据 maxLength 自动计算:先给整个组件一个可用宽度,减去格子之间的间距,再除以格子数。这样在平板或折叠屏上也不会挤在一起。
6. 一些实际操作后的心得
这个组件看起来简单,难点都在看不见的地方。我做过好几个版本,最大的体会是:能用系统控件解决的交互,不要自己硬造。透明 TextInput 这条路线之所以稳,就是因为它把键盘、粘贴、光标、输入法这些现代输入系统里最难的部分全部复用掉了,我们只需要做一个“翻译”:把系统输入框收到的字符串,翻译成一排格子里的字符。
如果你现在也在写鸿蒙登录页,我建议你新建一个SingleCharInput组件文件,先按上面的代码跑通六位验证码,再慢慢加手机号分段、重置、动效这些扩展。做的时候记得先把光标末尾锁定这个逻辑加上,这是很多初版体验崩坏的头号原因。
最后再分享一个小技巧:如果你担心透明 TextInput 在交付后被产品经理误认为是 bug,可以在开发阶段临时把fontColor改成一个半透明颜色,用来肉眼观察输入行为是否正常;功能验证完再改回透明即可。这个调试思路适用于所有“视觉隐藏但实际参与交互”的组件,能帮你少走很多弯路。