鸿蒙-验证码输入框的几种实现方式(上)

最近在做应用鸿蒙化,说白了就是把原来AndroidiOS的代码重新用ArkTS写一遍,我负责基础建设和登录模块,有个验证码输入框需要定制一下外观样式。这里详细记录一下探索过程及结果,以及思路和源码。这里给出了三种方案
一:多个 InputText 拼接,每个 InputText 只能输入 1 个字符,代码控制焦点移动,
二:多个 Text 拼接,通过系统apiinputMethod.InputMethodController控制键盘弹起并记录输入内容,刷新到 Text 中展示
三:使用 Canvas 自己绘制。
由于篇幅较长,这个拆成两篇来介绍。本篇介绍前两种方式,也是最常见的方式。使用Canvas 自己绘制纯粹就是闲着写的东西,后面再介绍。

效果图、优缺点

先放一下效果图

多TextInput

优点:只需要控制焦点就好,键盘的弹起、收起以及输入的内容我们不需要自己去监听,并且除了边框颜色之外,输入框内会有光标闪烁,也能给用户更强一些的提示
缺点:需要控制没有获取到焦点的输入框不能点击、不能长按等。尝试多种方案(设置 enable、focusable 等)均失败后,决定在输入框上面覆盖一个空白透明且大小和输入框相等的 Text 解决这个问题。有其他方案可以告诉我一下

多 Text

优点:不用控制焦点,只需要使用变量控制一下样式就好
缺点:需要自己记录键盘输入的文字,并且没有光标闪烁,当前也可以自己搞个 gif 图或者写个动画来模拟光标

Canvas 绘制

优点:我真的没想到有啥优点,可以自由的绘制边框、底色也算么?但现在的 pai 中有各种各样的Modifier来修改各种属性,实现自己绘制
缺点:全都得自己画,挺麻烦的

多个 TextInput 拼接

这里用四位验证码做例子:
思路挺简单的,四个TextInput并排放一块,输入框限制输入 1 个字符,用Flex做父控件也行,用Row做父控件也行,无所谓,这不是重点。
组件刚出现时,使用getUIContext().getFocusController().requestFocus(key:string)将焦点放在第一个输入框上,键盘就可以弹出来
监听TextInputonDidDeleteonChange或者onDidInsert事件,来判断下一个焦点放在哪个位置
当最后一个TextInput有输入字符时,认为输入完成,进行回调。

下面详细介绍一下每一步怎么做的,以及对应的想法

放置四个输入框

当前获取到焦点的输入框颜色要明亮一些,没有焦点的输入框颜色要暗淡一些。我们使用@Extend()做一个公用样式,传入当前是否是焦点控件来控制边框颜色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Extend(TextInput)
function textInputStyle(enable:boolean){
.border({
width: 1,
color: enable?"#1b91e0":"#999999",
radius: 4,
style: BorderStyle.Solid,
}).textAlign(TextAlign.Center)
.layoutWeight(1)
.maxLength(1)
.maxLines(1)
.type(InputType.Number)
.layoutWeight(1).height(40)
}

为了在焦点变化的时候输入框背景能同步修改,这里用一个@State修饰的布尔数组表示哪个输入框获取焦点。同时为了省事,也定义了另外一个数组,方便使用 ForEach循环渲染。定义另外一个字符串数组来记录每个输入框的内容

1
2
3
4
5
6
7
8
9
10
11
12
13
@State inputValue: string[] = ["", "", "", ""]// 输入框的内容
@State inputEnable: boolean[] = [true, false, false, false] //输入框是否获取焦点
inputIndex: number[] = [0, 1, 2, 3] //ForEach渲染用
build() {
Row() {//这里用 Flex 更方便一些,直接设置间距就好,不用这样设置 margin 了
ForEach(this.inputIndex, (index: number) => {
TextInput({ text: this.inputValue[index] })
.id(index.toString())//这里 id 是给FocusController使用
.margin({ right: index == this.inputIndex.length - 1 ? 0 : 10 })
.textInputStyle(this.inputEnable[index])
})
}
}

这样我们就画出来了最基本的布局。但我们会发现进入页面后键盘不能自己弹出来,因为输入框没有获取到焦点。
这里有两个方案,一个是给其中TextInput设置defaultFocus为 true,或者在页面展示的时候设置焦点

1
2
3
TextInput({ text: this.inputValue[index] }).defaultFocus(this.inputEnable[index])
//或者
Row(){}.onAppear(()=>{this.getUIContext().getFocusController().requestFocus("0")})

注意这里requestFocus()方法传入的参数"0",也就是上面TextInputid的值.
这样我们就做好的基本的属性,并且页面显示的时候也可以弹出键盘了。

焦点移动

输入时向后移动

但是这时候我们点击键盘输入的时候,发现光标并不会自动移动到下一个输入框上继续输入。这里需要我们进行控制。这个也比较简单,TextInput有一个输入内容发生变化时,触发的回调:onChange(callback: EditableTextOnChangeCallback)。我们可以在这个方法里面移动焦点

1
2
3
4
5
6
7
8
9
10
11
12
TextInput().onChange((value: string, previewText?: PreviewText) => {
this.inputValue[index] = value //记录输入的内容
if (value.length == 1) { //确认是输入而不是删除
if (index != 3) {//如果不是最后一个输入框发生的输入事件,就把焦点交给下一个输入框
this.inputEnable[index+1] = true//记录下一个输入框获取焦点,改变背景色
this.getUIContext().getFocusController().requestFocus((index + 1).toString())//下一个输入框获取焦点
this.inputEnable[index] = false//标记当前输入框失去焦点,改变背景色
} else {//如果是最后一个输入框发生的输入事件,表示已经输入完了,继续后面流程
//todo 输入完成,继续后面流程
}
}
})

输入完成回调

这就很简单了,定义一个函数变量,接收父布局传进来的函数,输入完成时回调这个函数就好

1
2
3
4
5
6
7
8
9
onFinishInput?: (value: string) => void  //函数变量
//输入完成,进行回调
if (this.onFinishInput) {//判断一下空值,然后把保存的值拼接成字符串回调
let result = ""
for (let i = 0; i < this.inputValue.length; i++) {
result += this.inputValue[i]
}
this.onFinishInput(result)
}

到这里,我们已经实现了大部分功能:输入字符、保存字符、焦点向后移动、输入完成时回调。
下面要解决的就是删除

删除时向前移动

这里需要注意一下:
如果当前输入框有内容,点击删除时删除当前输入框内容,焦点不动:仅在最后一个输入框会有这个情况
如果当前输入框没有内容,则删除上一个输入框内容,焦点移动到上一个输入框
如果当前时第一个输入框,不做处理

刚开始想着在onChange事件中做处理,但当输入框中没有内容时,点击删除的时是没有回调的。怎么搞,翻翻文档,找到了onWillDeleteonDidDelete,并且这两个回调都是发生在onChange之前。
这里我选择了onDidDelete事件中处理,逻辑就是上面说的那样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
TextInput().onDidDelete((_) => {

if (this.inputValue[index].length == 0) {
//不是第一个输入框 且 输入框内没有文字,则删除上一个输入框内容,并且使上一个输入框获取焦点
if (index != 0) {
this.inputValue[index-1] = ""
this.inputEnable[index] = false
this.inputEnable[index-1] = true

this.getUIContext().getFocusController().requestFocus((index - 1).toString())
} else {
//如果输入框内有文字,则只删除当前输入框内容
this.inputValue[index] = ""
}
}
})

到这里我们就完成了大部分的工作,一个可以正常工作,随便调整背景的验证码输入框就完成了。
但似乎还有一点问题,当点击非当前焦点的输入框时,光标会移动到点击的输入框中。这就不太好了

防止点击

一开始想法很简单,我们不是有个布尔类型数组保存着当前哪个输入框获取焦点么?没有焦点的输入框设置enable为false就可以了哇,移动焦点的时候先将目标输入框设置enable为true,然后再移动焦点就好了哇。
那就给输入框加上这个设置就好了哇

1
TextInput().enabled(this.inputEnable[index])

我们在上面的代码中,都是先修改inputEnable数组值,然后再设置焦点,完美
运行一下,页面出现时弹出键盘,除了第一个输入框其他输入框点击都没有反映,很好
试着输入一下,崩了。。。。
日志提示组件不存在或者时不可用状态

1
Error message:The component doesn't exist, is currently invisible, or has been disabled.

难道是因为在请求焦点的时候,输入框的属性还没来得及完成修改?
做个测试,延迟1s设置焦点,果然是可以的。但这种效果太难受了,键盘会先收起来再弹出来。缩短延迟时间也很难把握时长。那就再翻翻api,找找下一帧之类的回调。
还真有,在UIContext这个类中有一个函数postFrameCallback:注册一个在下一帧进行渲染时执行的回调。按照示例把代码撸好,编译运行,尝试输入,又又又崩了,错误信息也一样。
没办法了么?只能用延迟么?太难受了哇,来个曲线救国,我们搞个透明没有内容的控件覆盖在没有焦点的输入框上不就行了么。
于是我们得到了这样的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Row(){
ForEach(this.inputIndex,(index:number)=>{
RelativeContainer(){
TextInput({text:this.inputValue[index]})

if(!this.inputEnable[index]){//没有焦点则覆盖一个空白Text
Text().backgroundColor(Color.Transparent)
.alignRules({
left: {anchor: index.toString(), align: HorizontalAlign.Start},
top: {anchor: index.toString(), align: VerticalAlign.Top},
bottom: {anchor: index.toString(), align: VerticalAlign.Bottom},
right: {anchor: index.toString(), align: HorizontalAlign.End}
})
}
}.layoutWeight(1).height(40).margin({right:index == this.inputIndex.length-1?0:10})
})
}

到这里,就解决了点击非焦点输入框的问题。

总结

看起来挺简单的,实际上一点也不难。
还有可以优化的地方:
比如输入的内容可以不用数组,直接用字符串就好,删除和添加都是在末尾进行
比如焦点也可以不用记录的,直接用输入的字符串长度来判断就好。

遗留下的一个问题:
在上面使用enable来控制是否可点击时,为什么先设置enable为true,然后请求焦点会报错?
先设置enable为true,在下一帧时请求焦点还是报错。
这就留给大佬翻源码解释了。


附一个完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
import { hilog } from '@kit.PerformanceAnalysisKit'
@Component
struct FourTextInput {
onFinishInput?: (value: string) => void
@State inputValue: string[] = ["", "", "", ""]
@State inputEnable: boolean[] = [true, false, false, false]
inputIndex: number[] = [0, 1, 2, 3]

build() {
Row(){
ForEach(this.inputIndex,(index:number)=>{
RelativeContainer(){
TextInput({text:this.inputValue[index]}).layoutWeight(1).textInputStyle(this.inputEnable[index]).maxLength(1).maxLines(1).id(index.toString()).type(InputType.Number)
.onDidDelete((_)=>{
hilog.error(0x01,"InputVerificationCode",`第${index}个执行 onDidDelete`)
if(this.inputValue[index].length == 0){
//不是第一个输入框 且 输入框内没有文字,则删除上一个输入框内容,并且使上一个输入框获取焦点
if(index !=0){
this.inputValue[index-1]=""
this.inputEnable[index] =false
this.inputEnable[index-1] =true

this.getUIContext().getFocusController().requestFocus((index-1).toString())
}else{
//如果输入框内有文字,则只删除当前输入框内容
this.inputValue[index]=""
}
}
})
.onChange((value: string, previewText?: PreviewText)=>{
hilog.error(0x01,"InputVerificationCode",`第${index}个onChange: value:${value} previewText: value-> ${previewText?.value} offset->${previewText?.offset}` )
this.inputValue[index]= value
if(value.length == 1){
if(index != 3){
this.inputEnable[index+1] =true
this.getUIContext().getFocusController().requestFocus((index+1).toString())
this.inputEnable[index] = false
// this.inputEnable[index] =false
}else{
if(this.onFinishInput){
let result = ""
for(let i =0;i< this.inputValue.length;i++){
result += this.inputValue[i]
}
this.onFinishInput(result)
}
}
}
})
if(!this.inputEnable[index]){
Text().backgroundColor(Color.Transparent).alignRules({
left: {anchor: index.toString(), align: HorizontalAlign.Start},
top: {anchor: index.toString(), align: VerticalAlign.Top},
bottom: {anchor: index.toString(), align: VerticalAlign.Bottom},
right: {anchor: index.toString(), align: HorizontalAlign.End}
})
}
}.layoutWeight(1).height(40).margin({right:index == this.inputIndex.length-1?0:10})
})
}.onAppear(()=>{
this.getUIContext().getFocusController().requestFocus("0")
})
}
}

@Extend(TextInput)
function textInputStyle(enable: boolean) {
.border({
width: 1,
color: enable ? "#1b91e0" : "#999999",
radius: 4,
style: BorderStyle.Solid,
})
.textAlign(TextAlign.Center)
.layoutWeight(1)
.maxLength(1)
.maxLines(1)
.type(InputType.Number)
.layoutWeight(1)
.height(40)
}

export { FourTextInput }

鸿蒙-验证码输入框的几种实现方式(上)
https://blog.huangyuanlove.com/2024/09/10/鸿蒙-验证码输入框的几种实现方式-上/
作者
HuangYuan_xuan
发布于
2024年9月10日
许可协议
BY HUANG兄