1. 项目概述1.1 为什么需要「单格单字符」输入框做鸿蒙应用开发时,手机号、验证码这类输入场景是最常见的需求之一。但很多新手甚至有一定经验的开发者,第一反应都是直接用TextInput配合maxLength限制长度,再套一层样式就完事。这种方案在功能上没毛病,可在实际用户体验上,和主流App里那种“一格一字带下划线”的输入框差距很大。原因很简单:手机号输入时,用户希望知道自己输到第几位了;验证码输入时,用户期望是“一格一字”的视觉反馈,每个数字独立占位,底部带下划线,输完自动跳到下一格。原生TextInput做不到这种体验,因为它是“一段连续文本”,而不是“N个独立字符格子”。所以需要我们自己用ArkUI的组件组合去实现。这个项目就是我在鸿蒙应用里实现该交互的完整记录。最终效果是:屏幕上横向排列N个格子,每个格子底部有一条下划线,用户输入时,字符逐个填充到格子中,并且光标自动“跳格”,支持删除键回退。整个实现不依赖第三方库,全部用ArkUI声明式语法完成,核心代码量控制在200行以内。1.2 方案适用范围如果你正在做以下类型的页面,这个方案可以直接复用:手机号/邮箱/身份证等固定位数信息输入短信/语音验证码输入(4位、6位最常用)密码“单格圆点”输入(稍改渲染逻辑即可)任何需要“逐字符反馈”的输入场景适合的读者:刚接触鸿蒙ArkUI的初级开发者、想优化表单输入体验的中级开发者、以及需要在项目中快速落地该组件的工程师。1.3 关键技术点一览在动手写代码前,先明确这个方案需要解决的几个核心问题:布局问题:如何把N个格子横向等分排列,且自适应不同屏幕宽度。输入焦点问题:如何保证“点击任意格子”都能唤起输入法,且输入框始终处于激活状态。字符映射问题:如何把输入框里的连续字符串,拆分成单个字符并填充到对应格子中。交互细节:删除时如何回退、超过位数时如何限制、格式校验如何处理。下面逐个拆解。2. 整体设计与思路拆解2.1 核心设计思想:隐藏输入框 + 渲染格子这个功能最巧妙的点在于:用户看到的格子不是真正的输入框,真正的输入框被隐藏了。我用了一个几乎不可见的TextInput覆盖在整个格子区域上方,用户点击时,实际点中的是这个透明的TextInput,输入法正常弹出,字符正常接收。与此同时,通过onChange回调实时读取输入框内容,把每个字符拆出来,填充到下方渲染的N个格子中。为什么不用N个独立的TextInput?说实话我也试过,那是第一个踩坑版本:每个格子单独一个输入框,onFocus事件里手动把焦点切到下一个。但实测下来问题很多,比如:焦点切换时机难以控制,字符还没输入就被切走,体验很差。软键盘弹出时,多个输入框的focus状态难以管理。删除键在跨输入框回退时行为不一致,有的输入法直接不触发事件。所以后来参考了一些成熟方案,改成“单个隐藏输入框 + 状态驱动渲染”的思路,所有问题迎刃而解。这也是ArkUI声明式开发的核心优势:UI是状态的下游产物,只要状态正确,UI自动正确。2.2 为什么选择 Column + Row + Divider 组合具体到格子渲染,ArkUI里能用来做“下划线”的组件不少,比如Divider、Border,甚至Canvas。我最终选择的是:每个格子用Column包一个Text,底部用Divider绘制下划线。选择这个组合的理由:Column天然支持纵向排列:上面显示字符,下面显示线,结构清晰。Divider是独立的线条组件,可以单独控制颜色、粗细、间距,选中态和默认态切换时只需要改strokeWidth和color,不用动外层布局。如果用Border,虽然也能画底部线,但边框属于组件自身,想单独改某一边的粗细、颜色,还得自己写Border的EdgeWidths和EdgeColors,代码量反而更多。从状态管理的角度看,每个格子只需要两个状态:char(该格子的字符)和isActive(当前是否处于输入焦点位置)。这两个状态直接映射到UI:字符为空时格子显示占位符(比如一个浅色短横),字符非空时显示数字(或密码圆点);isActive为真时下划线高亮加粗,否则保持灰色细线。2.3 状态数据结构设计既然是ArkUI声明式开发,第一步就是定义状态。我定义的数据结构如下:@State inputValue: string = '' // 隐藏输入框的真实值 @State codeArray: string[] = [] // 每个格子的字符,初始为空字符串数组 @State activeIndex: number = 0 // 当前高亮的格子下标,同时也是“光标位置”inputValue是隐藏输入框的绑定值,一切输入都经过它;codeArray长度等于格子总数,初始全部是空字符串;activeIndex表示当前输入焦点对应的格子位置,控制下划线的高亮显示。他们三者之间的关系是这样的:用户输入字符 →inputValue变化 →onChange触发 → 更新codeArray→ 格子渲染变化用户删除字符 →inputValue变化 →onChange触发 →codeArray同步减少一个字符 → 格子回退这里有个非常有用的细节:activeIndex不需要单独维护“当前是第几格”,因为它永远等于codeArray中最后一个非空字符的下标加1。换句话说,只要统计codeArray里非空元素的数量,就知道光标停在哪。我后续的代码就是基于这个逻辑。3. 核心细节解析与实操要点3.1 两套视觉状态:默认态与激活态每一个格子都有两套视觉状态。默认态是灰色下划线、字符灰色;激活态是主题色下划线加粗,字符变黑。这种视觉反馈让用户知道自己正在输入哪个位置,对验证码这类短输入尤其重要。实现时我给Column绑定了一个状态判断:Button() // 伪代码思路,实际不是按钮 Column({ space: 4 }) { Text(this.getCharText(index)) .fontSize(28) .fontWeight(FontWeight.Medium) .fontColor(this.activeIndex === index ? '#1A1A1A' : '#999999') Divider() .strokeWidth(this.activeIndex === index ? 3 : 1.5) .color(this.activeIndex === index ? '#007AFF' : '#D0D0D0') }这里有个细节:fontColor和strokeWidth的变化如果太突兀,会显得很生硬,但验证码输入框本身就是“瞬间切换”的交互节奏,不需要动画。如果你想要平滑过渡,可以给这两个属性加.animation(),我在某些项目里试过,0.15秒的缓动效果最好,时间长了反而拖沓。3.2 隐藏输入框的三个关键设置隐藏输入框是整个方案的灵魂,但隐藏不等于“消失”。它必须满足三个条件:尺寸要能覆盖整个格子区域,这样点击任意格子都能聚焦到它。透明度要低到看不见,但不能设置visibility: Hidden,否则组件不参与布局也无法聚焦。文本颜色要设成透明,否则输入内容会露馅。我的实现如下:TextInput({ text: this.inputValue, placeholder: '' }) .type(InputType.NUMBER) // 数字键盘 .borderRadius(0) .backgroundColor(Color.Transparent) .caretColor(Color.Transparent) // 光标也隐藏 .fontColor(Color.Transparent) // 文本透明 .opacity(0.01) // 整个组件接近不可见 .focusable(true) .defaultFocus(true) // 页面加载后自动聚焦,直接弹出键盘 .width('100%') .height('100%') .position({ x: 0, y: 0 }) // 绝对定位覆盖格子区域实践经验提醒几点:.opacity(0.01)比.opacity(0)稳妥。某些鸿蒙版本上,opacity为0的组件会被判定为不可交互区域,点击无法聚焦。保留0.01的可见度可以避免这个坑。.caretColor(Color.Transparent)一定要设置,否则你能看到一个光标在闪,非常奇怪。defaultFocus(true)要慎用。如果页面还有别的输入需求,一进来就弹键盘可能不是用户预期;但对验证码页来说,弹键盘恰恰是减少用户操作的关键一步。别忘了给TextInput设置.onSubmit事件。用户输完最后一位后,自动触发“下一步”或“校验”逻辑,体验闭环。3.3 字符填充规则:长度限制与非法字符过滤输入框虽然是InputType.NUMBER,但为了防止极端情况(比如某些输入法允许粘贴字母),必须在onChange里做二次过滤。.onChange((value: string) => { // 1. 只保留数字字符 const filtered = value.replace(/[^0-9]/g, ''); // 2. 截取前 codeLen 位 const limited = filtered.substring(0, this.codeLen); // 3. 同步回填到隐藏输入框(防止非法字符短暂显示) this.inputValue = limited; // 4. 更新格子数组 this.codeArray = []; for (let i = 0; i < this.codeLen; i++) { this.codeArray.push(limited.charAt(i) || ''); } // 5. 更新激活位置(光标停在最后一个非空字符之后) this.activeIndex = limited.length; if (this.codeLen > 0 && limited.length >= this.codeLen) { // 输完了,可以自动触发下一步 } })这个处理逻辑把所有状态更新集中到了一处,简洁且可控。这里有一个容易踩的坑:不能直接用this.inputValue.length来计算激活下标。因为长度和内容不等于有效位数,一旦用户粘贴了非法字符,length会大于有效数字个数,导致activeIndex越界。我上面用了limited.length,它一定是合法且截断后的长度,不会出问题。还有一个细节:光标位置与activeIndex不完全等价。如果用户把光标移到中间插入字符,这种实现方式是“尾部追加”逻辑,不支持中间插入。对于手机号/验证码场景,这完全没问题,行为反而更符合预期:用户点击第3格,实际焦点还是落在隐藏输入框上,新增字符总是append到末尾。过度设计成“支持中间插入”反而会导致格子错乱。4. 实操过程与核心环节实现4.1 第一步:定义组件参数与属性我封装成了一个自定义组件CodeInputView,对外暴露两个参数:格子数量和完整输入回调。@Component export struct CodeInputView { // 格子数量,默认为6 @Prop codeLength: number = 6; // 输入完成回调 onCompleted: (code: string) => void = () => {}; @State inputValue: string = ''; @State codeArray: string[] = []; @State activeIndex: number = 0; aboutToAppear(): void { this.codeArray = new Array(this.codeLength).fill(''); } }这里要注意@Prop和@State的配合。codeLength由父组件传入,只在初始化时生效,后续如果动态改变格子数量,需要额外处理(比如用@Watch监听变化重置codeArray),但大多数场景下格子数量是固定的,@Prop足够。4.2 第二步:完整页面结构与网格布局页面的主体结构分三层:最外层是一个Stack容器,用于叠加隐藏输入框和格子层。第二层是Row,横向排列N个格子。第三层是每个格子的Column(字符 Text + 下划线 Divider)。build() { Stack({ alignContent: Alignment.Center }) { // 格子渲染层 Row({ space: 12 }) { ForEach(this.codeArray, (char: string, index: number) => { Column({ space: 6 }) { Text(char ? char : ' ') .fontSize(30) .fontWeight(FontWeight.Bold) .fontColor(this.activeIndex === index ? '#1A1A1A' : '#BBBBBB') .height(40) Divider() .strokeWidth(this.activeIndex === index ? 3 : 1.5) .color(this.activeIndex === index ? '#007AFF' : '#D8D8D8') } .width(this.getCellWidth()) .onClick(() => { // 点击格子时,确保隐藏输入框获得焦点 this.$inputController.setText(this.inputValue); }) }, (item: string, index: number) => `${index}-${item}`) } .width('100%') .padding({ left: 20, right: 20 }) // 隐藏输入层 TextInput({ text: this.inputValue, controller: this.$inputController }) // ... 上述透明设置 .position({ x: 0, y: 0 }) .width('100%') .height(56) } .width('100%') .height(70) }getCellWidth()这个方法值得说一下。如果格子数量是4/6等偶数,且屏幕宽度固定,可以简单做除法;但更好的做法是根据屏幕宽度动态计算,减去Row内边距和格子间距的总和:private getCellWidth(): string { const screenWidth = this.getScreenWidth(); // 通过 px2vp 或 window 获取 const spacingTotal = (this.codeLength - 1) * 12; const paddingTotal = 40; return `${(screenWidth - spacingTotal - paddingTotal) / this.codeLength}vp`; }实操中我推荐把间距固定为12vp,屏幕宽度用display.getDefaultDisplaySync().width获取。鸿蒙的vp单位在不同设备上能自适应,但px不行,务必用vp。4.3 第三步:处理删除回退与光标移动用户按删除键时,系统会先删除inputValue的最后一个字符,然后触发onChange,我们上面的事件处理逻辑已经涵盖了回退:limited.length变小,codeArray最后一项变成空,activeIndex自动回退。但onChange有一个坑:部分输入法在连续快速删除时,不会每个动作都触发onChange,而是间隔触发,导致UI更新不够及时。我的解决办法是额外监听onKeyEvent中的删除键事件,主动同步一次状态:.onKeyEvent((event: KeyEvent) => { if (event.type === KeyType.Down && event.keyCode === 2055) { // 删除键 this.$inputController.setText(this.inputValue.substring(0, this.inputValue.length - 1)); } })重点说明:2055是通用删除键的keyCode,不同设备/输入法可能会有差异,所以这个只能作为兜底方案。主力还得是onChange,它在绝大多数情况下都是可靠且及时的。4.4 第四步:完整输入完成判断与自动提交当codeArray中填满所有格子时,通常要自动触发后续动作。我在onChange里做了判断,输满后直接调用onCompleted:if (limited.length >= this.codeLength) { this.onCompleted(limited); }但如果用户删除后再重新输入,还是会走到这个判断。如果需要“只触发一次”,可以在组件里加一个@State submitted: boolean = false,首次提交后置为真,重置时再恢复。还有一个容易被忽略的细节:输入完成时隐藏键盘。用inputController.stopEditing()即可。否则用户输满后键盘还占着半屏,后面弹出的确认弹窗会被顶上去,体验很差。if (limited.length >= this.codeLength) { this.$inputController.stopEditing(); this.onCompleted(limited); }4.5 第五步:整段代码合并与页面调用把上述代码整理成一个完整的自定义组件后,在页面中调用:CodeInputView({ codeLength: 6, onCompleted: (code: string) => { // 发起验证码校验请求 } })到这里,核心功能已经全部实现:格子渲染、输入填充、删除回退、自动提交。我在多个项目里跑过这套逻辑,稳定性和流畅度都没问题。5. 常见问题与排查技巧实录5.1 点击格子无法唤起键盘现象:页面加载后可以自动弹键盘,但手动点击某个格子没反应。排查步骤:确认隐藏TextInput是否覆盖在格子区域上方。用opacity(1)临时显示它,看它的实际位置和大小对不对。确认position或者Stack的alignContent设置是否正确,通常是Stack里子组件的层级问题。检查TextInput是否被if条件错误地移除了。解决方案:把TextInput的宽高设成与格子区域相同,或者直接width('100%').height(56),配合Stack的默认对齐方式,基本能解决。另外,点击格子时用this.$inputController.setText(this.inputValue)强制刷新输入框内容,也能唤起输入法。5.2 输入法每次都显示字母键盘现象:.type(InputType.NUMBER)也设置了,但弹出的还是全键盘。原因分析:鸿蒙部分版本对InputType.NUMBER的支持不彻底,或者输入法App自身设定优先。这种通常出现在第三方输入法上,系统自带输入法一般没问题。解决方案:再加一行.enableKeyboardOnFocus(true),如果还不行,就换用InputType.PHONE_NUMBER,它在大多数输入法下会稳定弹出数字键盘。实测中PHONE_NUMBER比NUMBER兼容性更好,因为后者有时被输入法当作“小数输入”处理,会出现小数点键位。5.3 高亮下划线显示错误现象:输入到第3位,但第2格的下划线高亮了。排查思路:这是activeIndex计算错误。回顾逻辑:activeIndex = limited.length,如果limited被截断或过滤后长度不对,光标位置就会偏。在代码里打印一下limited.length和codeArray,两相对比就知道哪里出问题了。常见原因:codeArray初始化长度与实际codeLength不一致,比如父组件传入codeLength=6,但子组件aboutToAppear里用new Array(4)初始化,导致下标错位。解决方案:强制在aboutToAppear里用this.codeLength初始化codeArray,并在onChange里也一样用this.codeLength截断,两处保持一致。5.4 单格单字符模式下粘贴不生效现象:用户通过输入法粘贴一串验证码,结果只有第一位显示了。原因分析:部分输入法的粘贴行为会逐字符触发onChange,而且每次触发时传入的是“增量”而非“全量”,我们基于全量字符串的覆盖逻辑就会被后面的增量覆盖掉。解决方案:在onChange里不要直接用传入的value,而是读取this.inputController.getTextContent()拿输入框的真实全量内容,或者把传入值和当前状态做合并:const finalValue = value.length > this.inputValue.length ? value : this.inputValue;这个方法不是100%可靠,但能解决多数场景。更稳的做法是在onPaste事件里拦截并手动赋值。我在一个金融类App项目里遇到过粘贴不生效的问题,最后就是用onPaste拦截,解析剪贴板内容后直接this.inputValue = parsedCode,效果最好。5.5 页面回退后输入框状态残留现象:从验证码页退到上一个页面,再回来,输入框还是上次的输入内容。原因分析:页面没有被销毁,@State数据还保留着。解决方案:在页面onPageHide或aboutToDisappear里清空状态。如果是用Navigation管理的多页面,可以在onHidden回调里执行:this.inputValue = ''; this.codeArray = new Array(this.codeLength).fill(''); this.activeIndex = 0;这一步看着简单,但很多测试同学会专门去验证“退出重进”后的状态问题,建议从一开始就加上。6. 性能优化与扩展方向6.1 减少不必要的组件重绘ArkUI的ForEach在列表渲染时,如果item的key不唯一或频繁变化,会导致整个ForEach重建。我在生成key时用了${index}-${item},这样即使字符变化,只要下标不变,key就稳定,组件不会重建,只会更新Text内容。这一点在格子数量少时看不出差别,但如果未来做成“动态长度”输入(比如短信验证码可能是6位也可能是8位),或者格子数量很多时,就会明显影响流畅度。实测在低端设备上,不写key和写key的渲染耗时差距能有3倍。6.2 密码模式扩展把Text的内容从纯数字改成圆点(如●),只需要一个三元判断:Text(char ? '●' : ' ')并且把fontSize调小一点,因为圆点视觉上比数字大。密码输入时通常不想看到真实的字符,这个扩展基本零成本。6.3 支持倒计时与自动校验联动验证码输入框通常伴随“获取验证码”按钮倒计时。组件可以对外暴露一个方法,供父组件调用重置:public resetCodeInput(): void { this.inputValue = ''; this.codeArray = new Array(this.codeLength).fill(''); this.activeIndex = 0; }配合@State的响应式机制,父组件修改codeLength时,子组件也能通过@Watch自动重置:@Watch('onCodeLengthChange') @Prop codeLength: number = 6; onCodeLengthChange() { this.codeArray = new Array(this.codeLength).fill(''); this.inputValue = ''; this.activeIndex = 0; }6.4 深色模式适配目前色值是写死的浅色主题色。鸿蒙的深色模式适配很简单:用Resource颜色替代硬编码色值,在resources/base/element/color.json和resources/dark/element/color.json里分别定义同一名称的颜色,在代码里引用$r('app.color.input_line_default')即可。下划线的默认色和高亮色、字符颜色各定义一对,适配工作量很小。7. 实操心得与踩坑总结整个组件从第一版到现在,踩过最大的坑有两个,值得单独拿出来说。第一个是输入法“增量回调”问题。第一版我天真地以为onChange每次都会给我完整的字符串,直到某台测试机上反复出现字符错位,才发现部分输入法粘贴时是分段回调的。后来改成以输入框控制器读到的真实内容为准,同时在过滤正则上做了兜底,才算彻底稳定。第二个是透明输入框的opacity设置。之前踩过一次opacity(0)后组件完全无法交互的坑,查了半天文档才定位到是鸿蒙对“完全透明组件”的点击穿透处理。所以现在用0.01这个数值,既满足视觉隐藏,又保留交互能力。要说这套方案的取舍,我认为它把“输入体验”和“视觉表现”做了很好的解耦。真正的输入逻辑只有一个隐藏输入框承载,不用管焦点分发和跨组件事件,天然没有竞态问题;而视觉部分完全交给状态驱动,改样式时只需动Text和Divider,不用碰输入逻辑。如果你是在做一个对输入体验有要求的鸿蒙应用,或者想给自己的组件库加一个验证码输入组件,这套方案可以直接照搬。后续还可以往振动反馈、音频提示、错误状态抖动这些方向扩展,本质上都是给同一个状态模型加效果,扩展性很好。
1. 项目概述1.1 为什么需要「单格单字符」输入框做鸿蒙应用开发时,手机号、验证码这类输入场景是最常见的需求之一。但很多新手甚至有一定经验的开发者,第一反应都是直接用TextInput配合maxLength限制长度,再套一层样式就完事。这种方案在功能上没毛病,可在实际用户体验上,和主流App里那种“一格一字带下划线”的输入框差距很大。原因很简单:手机号输入时,用户希望知道自己输到第几位了;验证码输入时,用户期望是“一格一字”的视觉反馈,每个数字独立占位,底部带下划线,输完自动跳到下一格。原生TextInput做不到这种体验,因为它是“一段连续文本”,而不是“N个独立字符格子”。所以需要我们自己用ArkUI的组件组合去实现。这个项目就是我在鸿蒙应用里实现该交互的完整记录。最终效果是:屏幕上横向排列N个格子,每个格子底部有一条下划线,用户输入时,字符逐个填充到格子中,并且光标自动“跳格”,支持删除键回退。整个实现不依赖第三方库,全部用ArkUI声明式语法完成,核心代码量控制在200行以内。1.2 方案适用范围如果你正在做以下类型的页面,这个方案可以直接复用:手机号/邮箱/身份证等固定位数信息输入短信/语音验证码输入(4位、6位最常用)密码“单格圆点”输入(稍改渲染逻辑即可)任何需要“逐字符反馈”的输入场景适合的读者:刚接触鸿蒙ArkUI的初级开发者、想优化表单输入体验的中级开发者、以及需要在项目中快速落地该组件的工程师。1.3 关键技术点一览在动手写代码前,先明确这个方案需要解决的几个核心问题:布局问题:如何把N个格子横向等分排列,且自适应不同屏幕宽度。输入焦点问题:如何保证“点击任意格子”都能唤起输入法,且输入框始终处于激活状态。字符映射问题:如何把输入框里的连续字符串,拆分成单个字符并填充到对应格子中。交互细节:删除时如何回退、超过位数时如何限制、格式校验如何处理。下面逐个拆解。2. 整体设计与思路拆解2.1 核心设计思想:隐藏输入框 + 渲染格子这个功能最巧妙的点在于:用户看到的格子不是真正的输入框,真正的输入框被隐藏了。我用了一个几乎不可见的TextInput覆盖在整个格子区域上方,用户点击时,实际点中的是这个透明的TextInput,输入法正常弹出,字符正常接收。与此同时,通过onChange回调实时读取输入框内容,把每个字符拆出来,填充到下方渲染的N个格子中。为什么不用N个独立的TextInput?说实话我也试过,那是第一个踩坑版本:每个格子单独一个输入框,onFocus事件里手动把焦点切到下一个。但实测下来问题很多,比如:焦点切换时机难以控制,字符还没输入就被切走,体验很差。软键盘弹出时,多个输入框的focus状态难以管理。删除键在跨输入框回退时行为不一致,有的输入法直接不触发事件。所以后来参考了一些成熟方案,改成“单个隐藏输入框 + 状态驱动渲染”的思路,所有问题迎刃而解。这也是ArkUI声明式开发的核心优势:UI是状态的下游产物,只要状态正确,UI自动正确。2.2 为什么选择 Column + Row + Divider 组合具体到格子渲染,ArkUI里能用来做“下划线”的组件不少,比如Divider、Border,甚至Canvas。我最终选择的是:每个格子用Column包一个Text,底部用Divider绘制下划线。选择这个组合的理由:Column天然支持纵向排列:上面显示字符,下面显示线,结构清晰。Divider是独立的线条组件,可以单独控制颜色、粗细、间距,选中态和默认态切换时只需要改strokeWidth和color,不用动外层布局。如果用Border,虽然也能画底部线,但边框属于组件自身,想单独改某一边的粗细、颜色,还得自己写Border的EdgeWidths和EdgeColors,代码量反而更多。从状态管理的角度看,每个格子只需要两个状态:char(该格子的字符)和isActive(当前是否处于输入焦点位置)。这两个状态直接映射到UI:字符为空时格子显示占位符(比如一个浅色短横),字符非空时显示数字(或密码圆点);isActive为真时下划线高亮加粗,否则保持灰色细线。2.3 状态数据结构设计既然是ArkUI声明式开发,第一步就是定义状态。我定义的数据结构如下:@State inputValue: string = '' // 隐藏输入框的真实值 @State codeArray: string[] = [] // 每个格子的字符,初始为空字符串数组 @State activeIndex: number = 0 // 当前高亮的格子下标,同时也是“光标位置”inputValue是隐藏输入框的绑定值,一切输入都经过它;codeArray长度等于格子总数,初始全部是空字符串;activeIndex表示当前输入焦点对应的格子位置,控制下划线的高亮显示。他们三者之间的关系是这样的:用户输入字符 →inputValue变化 →onChange触发 → 更新codeArray→ 格子渲染变化用户删除字符 →inputValue变化 →onChange触发 →codeArray同步减少一个字符 → 格子回退这里有个非常有用的细节:activeIndex不需要单独维护“当前是第几格”,因为它永远等于codeArray中最后一个非空字符的下标加1。换句话说,只要统计codeArray里非空元素的数量,就知道光标停在哪。我后续的代码就是基于这个逻辑。3. 核心细节解析与实操要点3.1 两套视觉状态:默认态与激活态每一个格子都有两套视觉状态。默认态是灰色下划线、字符灰色;激活态是主题色下划线加粗,字符变黑。这种视觉反馈让用户知道自己正在输入哪个位置,对验证码这类短输入尤其重要。实现时我给Column绑定了一个状态判断:Button() // 伪代码思路,实际不是按钮 Column({ space: 4 }) { Text(this.getCharText(index)) .fontSize(28) .fontWeight(FontWeight.Medium) .fontColor(this.activeIndex === index ? '#1A1A1A' : '#999999') Divider() .strokeWidth(this.activeIndex === index ? 3 : 1.5) .color(this.activeIndex === index ? '#007AFF' : '#D0D0D0') }这里有个细节:fontColor和strokeWidth的变化如果太突兀,会显得很生硬,但验证码输入框本身就是“瞬间切换”的交互节奏,不需要动画。如果你想要平滑过渡,可以给这两个属性加.animation(),我在某些项目里试过,0.15秒的缓动效果最好,时间长了反而拖沓。3.2 隐藏输入框的三个关键设置隐藏输入框是整个方案的灵魂,但隐藏不等于“消失”。它必须满足三个条件:尺寸要能覆盖整个格子区域,这样点击任意格子都能聚焦到它。透明度要低到看不见,但不能设置visibility: Hidden,否则组件不参与布局也无法聚焦。文本颜色要设成透明,否则输入内容会露馅。我的实现如下:TextInput({ text: this.inputValue, placeholder: '' }) .type(InputType.NUMBER) // 数字键盘 .borderRadius(0) .backgroundColor(Color.Transparent) .caretColor(Color.Transparent) // 光标也隐藏 .fontColor(Color.Transparent) // 文本透明 .opacity(0.01) // 整个组件接近不可见 .focusable(true) .defaultFocus(true) // 页面加载后自动聚焦,直接弹出键盘 .width('100%') .height('100%') .position({ x: 0, y: 0 }) // 绝对定位覆盖格子区域实践经验提醒几点:.opacity(0.01)比.opacity(0)稳妥。某些鸿蒙版本上,opacity为0的组件会被判定为不可交互区域,点击无法聚焦。保留0.01的可见度可以避免这个坑。.caretColor(Color.Transparent)一定要设置,否则你能看到一个光标在闪,非常奇怪。defaultFocus(true)要慎用。如果页面还有别的输入需求,一进来就弹键盘可能不是用户预期;但对验证码页来说,弹键盘恰恰是减少用户操作的关键一步。别忘了给TextInput设置.onSubmit事件。用户输完最后一位后,自动触发“下一步”或“校验”逻辑,体验闭环。3.3 字符填充规则:长度限制与非法字符过滤输入框虽然是InputType.NUMBER,但为了防止极端情况(比如某些输入法允许粘贴字母),必须在onChange里做二次过滤。.onChange((value: string) => { // 1. 只保留数字字符 const filtered = value.replace(/[^0-9]/g, ''); // 2. 截取前 codeLen 位 const limited = filtered.substring(0, this.codeLen); // 3. 同步回填到隐藏输入框(防止非法字符短暂显示) this.inputValue = limited; // 4. 更新格子数组 this.codeArray = []; for (let i = 0; i < this.codeLen; i++) { this.codeArray.push(limited.charAt(i) || ''); } // 5. 更新激活位置(光标停在最后一个非空字符之后) this.activeIndex = limited.length; if (this.codeLen > 0 && limited.length >= this.codeLen) { // 输完了,可以自动触发下一步 } })这个处理逻辑把所有状态更新集中到了一处,简洁且可控。这里有一个容易踩的坑:不能直接用this.inputValue.length来计算激活下标。因为长度和内容不等于有效位数,一旦用户粘贴了非法字符,length会大于有效数字个数,导致activeIndex越界。我上面用了limited.length,它一定是合法且截断后的长度,不会出问题。还有一个细节:光标位置与activeIndex不完全等价。如果用户把光标移到中间插入字符,这种实现方式是“尾部追加”逻辑,不支持中间插入。对于手机号/验证码场景,这完全没问题,行为反而更符合预期:用户点击第3格,实际焦点还是落在隐藏输入框上,新增字符总是append到末尾。过度设计成“支持中间插入”反而会导致格子错乱。4. 实操过程与核心环节实现4.1 第一步:定义组件参数与属性我封装成了一个自定义组件CodeInputView,对外暴露两个参数:格子数量和完整输入回调。@Component export struct CodeInputView { // 格子数量,默认为6 @Prop codeLength: number = 6; // 输入完成回调 onCompleted: (code: string) => void = () => {}; @State inputValue: string = ''; @State codeArray: string[] = []; @State activeIndex: number = 0; aboutToAppear(): void { this.codeArray = new Array(this.codeLength).fill(''); } }这里要注意@Prop和@State的配合。codeLength由父组件传入,只在初始化时生效,后续如果动态改变格子数量,需要额外处理(比如用@Watch监听变化重置codeArray),但大多数场景下格子数量是固定的,@Prop足够。4.2 第二步:完整页面结构与网格布局页面的主体结构分三层:最外层是一个Stack容器,用于叠加隐藏输入框和格子层。第二层是Row,横向排列N个格子。第三层是每个格子的Column(字符 Text + 下划线 Divider)。build() { Stack({ alignContent: Alignment.Center }) { // 格子渲染层 Row({ space: 12 }) { ForEach(this.codeArray, (char: string, index: number) => { Column({ space: 6 }) { Text(char ? char : ' ') .fontSize(30) .fontWeight(FontWeight.Bold) .fontColor(this.activeIndex === index ? '#1A1A1A' : '#BBBBBB') .height(40) Divider() .strokeWidth(this.activeIndex === index ? 3 : 1.5) .color(this.activeIndex === index ? '#007AFF' : '#D8D8D8') } .width(this.getCellWidth()) .onClick(() => { // 点击格子时,确保隐藏输入框获得焦点 this.$inputController.setText(this.inputValue); }) }, (item: string, index: number) => `${index}-${item}`) } .width('100%') .padding({ left: 20, right: 20 }) // 隐藏输入层 TextInput({ text: this.inputValue, controller: this.$inputController }) // ... 上述透明设置 .position({ x: 0, y: 0 }) .width('100%') .height(56) } .width('100%') .height(70) }getCellWidth()这个方法值得说一下。如果格子数量是4/6等偶数,且屏幕宽度固定,可以简单做除法;但更好的做法是根据屏幕宽度动态计算,减去Row内边距和格子间距的总和:private getCellWidth(): string { const screenWidth = this.getScreenWidth(); // 通过 px2vp 或 window 获取 const spacingTotal = (this.codeLength - 1) * 12; const paddingTotal = 40; return `${(screenWidth - spacingTotal - paddingTotal) / this.codeLength}vp`; }实操中我推荐把间距固定为12vp,屏幕宽度用display.getDefaultDisplaySync().width获取。鸿蒙的vp单位在不同设备上能自适应,但px不行,务必用vp。4.3 第三步:处理删除回退与光标移动用户按删除键时,系统会先删除inputValue的最后一个字符,然后触发onChange,我们上面的事件处理逻辑已经涵盖了回退:limited.length变小,codeArray最后一项变成空,activeIndex自动回退。但onChange有一个坑:部分输入法在连续快速删除时,不会每个动作都触发onChange,而是间隔触发,导致UI更新不够及时。我的解决办法是额外监听onKeyEvent中的删除键事件,主动同步一次状态:.onKeyEvent((event: KeyEvent) => { if (event.type === KeyType.Down && event.keyCode === 2055) { // 删除键 this.$inputController.setText(this.inputValue.substring(0, this.inputValue.length - 1)); } })重点说明:2055是通用删除键的keyCode,不同设备/输入法可能会有差异,所以这个只能作为兜底方案。主力还得是onChange,它在绝大多数情况下都是可靠且及时的。4.4 第四步:完整输入完成判断与自动提交当codeArray中填满所有格子时,通常要自动触发后续动作。我在onChange里做了判断,输满后直接调用onCompleted:if (limited.length >= this.codeLength) { this.onCompleted(limited); }但如果用户删除后再重新输入,还是会走到这个判断。如果需要“只触发一次”,可以在组件里加一个@State submitted: boolean = false,首次提交后置为真,重置时再恢复。还有一个容易被忽略的细节:输入完成时隐藏键盘。用inputController.stopEditing()即可。否则用户输满后键盘还占着半屏,后面弹出的确认弹窗会被顶上去,体验很差。if (limited.length >= this.codeLength) { this.$inputController.stopEditing(); this.onCompleted(limited); }4.5 第五步:整段代码合并与页面调用把上述代码整理成一个完整的自定义组件后,在页面中调用:CodeInputView({ codeLength: 6, onCompleted: (code: string) => { // 发起验证码校验请求 } })到这里,核心功能已经全部实现:格子渲染、输入填充、删除回退、自动提交。我在多个项目里跑过这套逻辑,稳定性和流畅度都没问题。5. 常见问题与排查技巧实录5.1 点击格子无法唤起键盘现象:页面加载后可以自动弹键盘,但手动点击某个格子没反应。排查步骤:确认隐藏TextInput是否覆盖在格子区域上方。用opacity(1)临时显示它,看它的实际位置和大小对不对。确认position或者Stack的alignContent设置是否正确,通常是Stack里子组件的层级问题。检查TextInput是否被if条件错误地移除了。解决方案:把TextInput的宽高设成与格子区域相同,或者直接width('100%').height(56),配合Stack的默认对齐方式,基本能解决。另外,点击格子时用this.$inputController.setText(this.inputValue)强制刷新输入框内容,也能唤起输入法。5.2 输入法每次都显示字母键盘现象:.type(InputType.NUMBER)也设置了,但弹出的还是全键盘。原因分析:鸿蒙部分版本对InputType.NUMBER的支持不彻底,或者输入法App自身设定优先。这种通常出现在第三方输入法上,系统自带输入法一般没问题。解决方案:再加一行.enableKeyboardOnFocus(true),如果还不行,就换用InputType.PHONE_NUMBER,它在大多数输入法下会稳定弹出数字键盘。实测中PHONE_NUMBER比NUMBER兼容性更好,因为后者有时被输入法当作“小数输入”处理,会出现小数点键位。5.3 高亮下划线显示错误现象:输入到第3位,但第2格的下划线高亮了。排查思路:这是activeIndex计算错误。回顾逻辑:activeIndex = limited.length,如果limited被截断或过滤后长度不对,光标位置就会偏。在代码里打印一下limited.length和codeArray,两相对比就知道哪里出问题了。常见原因:codeArray初始化长度与实际codeLength不一致,比如父组件传入codeLength=6,但子组件aboutToAppear里用new Array(4)初始化,导致下标错位。解决方案:强制在aboutToAppear里用this.codeLength初始化codeArray,并在onChange里也一样用this.codeLength截断,两处保持一致。5.4 单格单字符模式下粘贴不生效现象:用户通过输入法粘贴一串验证码,结果只有第一位显示了。原因分析:部分输入法的粘贴行为会逐字符触发onChange,而且每次触发时传入的是“增量”而非“全量”,我们基于全量字符串的覆盖逻辑就会被后面的增量覆盖掉。解决方案:在onChange里不要直接用传入的value,而是读取this.inputController.getTextContent()拿输入框的真实全量内容,或者把传入值和当前状态做合并:const finalValue = value.length > this.inputValue.length ? value : this.inputValue;这个方法不是100%可靠,但能解决多数场景。更稳的做法是在onPaste事件里拦截并手动赋值。我在一个金融类App项目里遇到过粘贴不生效的问题,最后就是用onPaste拦截,解析剪贴板内容后直接this.inputValue = parsedCode,效果最好。5.5 页面回退后输入框状态残留现象:从验证码页退到上一个页面,再回来,输入框还是上次的输入内容。原因分析:页面没有被销毁,@State数据还保留着。解决方案:在页面onPageHide或aboutToDisappear里清空状态。如果是用Navigation管理的多页面,可以在onHidden回调里执行:this.inputValue = ''; this.codeArray = new Array(this.codeLength).fill(''); this.activeIndex = 0;这一步看着简单,但很多测试同学会专门去验证“退出重进”后的状态问题,建议从一开始就加上。6. 性能优化与扩展方向6.1 减少不必要的组件重绘ArkUI的ForEach在列表渲染时,如果item的key不唯一或频繁变化,会导致整个ForEach重建。我在生成key时用了${index}-${item},这样即使字符变化,只要下标不变,key就稳定,组件不会重建,只会更新Text内容。这一点在格子数量少时看不出差别,但如果未来做成“动态长度”输入(比如短信验证码可能是6位也可能是8位),或者格子数量很多时,就会明显影响流畅度。实测在低端设备上,不写key和写key的渲染耗时差距能有3倍。6.2 密码模式扩展把Text的内容从纯数字改成圆点(如●),只需要一个三元判断:Text(char ? '●' : ' ')并且把fontSize调小一点,因为圆点视觉上比数字大。密码输入时通常不想看到真实的字符,这个扩展基本零成本。6.3 支持倒计时与自动校验联动验证码输入框通常伴随“获取验证码”按钮倒计时。组件可以对外暴露一个方法,供父组件调用重置:public resetCodeInput(): void { this.inputValue = ''; this.codeArray = new Array(this.codeLength).fill(''); this.activeIndex = 0; }配合@State的响应式机制,父组件修改codeLength时,子组件也能通过@Watch自动重置:@Watch('onCodeLengthChange') @Prop codeLength: number = 6; onCodeLengthChange() { this.codeArray = new Array(this.codeLength).fill(''); this.inputValue = ''; this.activeIndex = 0; }6.4 深色模式适配目前色值是写死的浅色主题色。鸿蒙的深色模式适配很简单:用Resource颜色替代硬编码色值,在resources/base/element/color.json和resources/dark/element/color.json里分别定义同一名称的颜色,在代码里引用$r('app.color.input_line_default')即可。下划线的默认色和高亮色、字符颜色各定义一对,适配工作量很小。7. 实操心得与踩坑总结整个组件从第一版到现在,踩过最大的坑有两个,值得单独拿出来说。第一个是输入法“增量回调”问题。第一版我天真地以为onChange每次都会给我完整的字符串,直到某台测试机上反复出现字符错位,才发现部分输入法粘贴时是分段回调的。后来改成以输入框控制器读到的真实内容为准,同时在过滤正则上做了兜底,才算彻底稳定。第二个是透明输入框的opacity设置。之前踩过一次opacity(0)后组件完全无法交互的坑,查了半天文档才定位到是鸿蒙对“完全透明组件”的点击穿透处理。所以现在用0.01这个数值,既满足视觉隐藏,又保留交互能力。要说这套方案的取舍,我认为它把“输入体验”和“视觉表现”做了很好的解耦。真正的输入逻辑只有一个隐藏输入框承载,不用管焦点分发和跨组件事件,天然没有竞态问题;而视觉部分完全交给状态驱动,改样式时只需动Text和Divider,不用碰输入逻辑。如果你是在做一个对输入体验有要求的鸿蒙应用,或者想给自己的组件库加一个验证码输入组件,这套方案可以直接照搬。后续还可以往振动反馈、音频提示、错误状态抖动这些方向扩展,本质上都是给同一个状态模型加效果,扩展性很好。