鸿蒙实战:PhoneLoginPage 6位验证码 Cell 设计与 60 秒倒计时
前言

图:鸿蒙实战第90篇:PhoneLoginPage 6位验证码 Cell 设计与 60 秒倒计时 运行效果截图(HarmonyOS NEXT)
手机验证码登录是移动 App 最常见的登录方式之一,但在 ArkUI 中实现"6 格验证码输入框"并不简单------没有现成的 PinInput 或 OtpInput 组件。本篇完整拆解 PhoneLoginPage 的 514 行代码,重点解析:
- 隐藏 TextInput + 6 个 Stack Cell 的组合设计(系统键盘 + 自定义视觉)
- Cell 三态渲染(空白 / 光标 / 数字)
- 60 秒倒计时 (
setInterval+ 状态切换) - 手机号正则校验与双输入联合按钮控制
系列导航:89 LoginPage 华为一键登录 · 91 AnalyzingPage 分析数据流
相关文档:TextInput API · setInterval
一、页面整体架构
1.1 状态变量
typescript
@Entry
@Component
struct PhoneLoginPage {
@State phoneNumber: string = '' // 手机号(11位)
@State verifyCode: string = '' // 验证码(6位)
@State phoneFocus: boolean = false // 手机号输入框聚焦
@State codeFocus: boolean = false // 验证码聚焦
@State isCountingDown: boolean = false// 倒计时中
@State countdown: number = 60 // 剩余秒数
@State isLoggingIn: boolean = false // 登录中
private countdownTimer: number = -1 // setInterval 返回的 ID
}
1.2 完整交互流程
UserDao + AppStorage SMS 服务(调试:Mock) PhoneLoginPage 用户 UserDao + AppStorage SMS 服务(调试:Mock) PhoneLoginPage 用户 #mermaid-svg-8kY9ZeBknklcYfsv{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-8kY9ZeBknklcYfsv .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-8kY9ZeBknklcYfsv .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-8kY9ZeBknklcYfsv .error-icon{fill:#552222;}#mermaid-svg-8kY9ZeBknklcYfsv .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-8kY9ZeBknklcYfsv .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-8kY9ZeBknklcYfsv .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-8kY9ZeBknklcYfsv .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-8kY9ZeBknklcYfsv .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-8kY9ZeBknklcYfsv .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-8kY9ZeBknklcYfsv .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-8kY9ZeBknklcYfsv .marker{fill:#333333;stroke:#333333;}#mermaid-svg-8kY9ZeBknklcYfsv .marker.cross{stroke:#333333;}#mermaid-svg-8kY9ZeBknklcYfsv svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-8kY9ZeBknklcYfsv p{margin:0;}#mermaid-svg-8kY9ZeBknklcYfsv .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-8kY9ZeBknklcYfsv text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-8kY9ZeBknklcYfsv .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-8kY9ZeBknklcYfsv .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-8kY9ZeBknklcYfsv .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-8kY9ZeBknklcYfsv .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-8kY9ZeBknklcYfsv #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-8kY9ZeBknklcYfsv .sequenceNumber{fill:white;}#mermaid-svg-8kY9ZeBknklcYfsv #sequencenumber{fill:#333;}#mermaid-svg-8kY9ZeBknklcYfsv #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-8kY9ZeBknklcYfsv .messageText{fill:#333;stroke:none;}#mermaid-svg-8kY9ZeBknklcYfsv .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-8kY9ZeBknklcYfsv .labelText,#mermaid-svg-8kY9ZeBknklcYfsv .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-8kY9ZeBknklcYfsv .loopText,#mermaid-svg-8kY9ZeBknklcYfsv .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-8kY9ZeBknklcYfsv .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-8kY9ZeBknklcYfsv .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-8kY9ZeBknklcYfsv .noteText,#mermaid-svg-8kY9ZeBknklcYfsv .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-8kY9ZeBknklcYfsv .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-8kY9ZeBknklcYfsv .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-8kY9ZeBknklcYfsv .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-8kY9ZeBknklcYfsv .actorPopupMenu{position:absolute;}#mermaid-svg-8kY9ZeBknklcYfsv .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-8kY9ZeBknklcYfsv .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-8kY9ZeBknklcYfsv .actor-man circle,#mermaid-svg-8kY9ZeBknklcYfsv line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-8kY9ZeBknklcYfsv :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 输入手机号(11位) 点击"获取验证码" 正则校验 ^13-9\d{9} sendVerifyCode(phone) 返回成功(Mock: 延迟800ms) 启动 60s setInterval 倒计时 输入 6 位验证码 正则校验 \^\\d{6} 点击"登录" verifyCode(phone, code) 验证通过 UserDao.ensureLocalUser DemoSeeder.seedIfNeeded AppStorage.setOrCreate replaceUrl → HomePage
二、6 位验证码 Cell 组件设计
2.1 核心思路
ArkUI 没有内置 OTP/Pin 输入组件,需要用以下组合实现:
- 隐藏的
TextInput:opacity: 0、height: 0,只负责接收系统键盘输入 - 6 个可见
StackCell:只负责展示当前输入内容 $$this.verifyCode双向绑定:隐藏输入框的值实时同步到状态变量
#mermaid-svg-WqipWrOlyEkvHx44{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-WqipWrOlyEkvHx44 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-WqipWrOlyEkvHx44 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-WqipWrOlyEkvHx44 .error-icon{fill:#552222;}#mermaid-svg-WqipWrOlyEkvHx44 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-WqipWrOlyEkvHx44 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-WqipWrOlyEkvHx44 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-WqipWrOlyEkvHx44 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-WqipWrOlyEkvHx44 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-WqipWrOlyEkvHx44 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-WqipWrOlyEkvHx44 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-WqipWrOlyEkvHx44 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-WqipWrOlyEkvHx44 .marker.cross{stroke:#333333;}#mermaid-svg-WqipWrOlyEkvHx44 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-WqipWrOlyEkvHx44 p{margin:0;}#mermaid-svg-WqipWrOlyEkvHx44 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-WqipWrOlyEkvHx44 .cluster-label text{fill:#333;}#mermaid-svg-WqipWrOlyEkvHx44 .cluster-label span{color:#333;}#mermaid-svg-WqipWrOlyEkvHx44 .cluster-label span p{background-color:transparent;}#mermaid-svg-WqipWrOlyEkvHx44 .label text,#mermaid-svg-WqipWrOlyEkvHx44 span{fill:#333;color:#333;}#mermaid-svg-WqipWrOlyEkvHx44 .node rect,#mermaid-svg-WqipWrOlyEkvHx44 .node circle,#mermaid-svg-WqipWrOlyEkvHx44 .node ellipse,#mermaid-svg-WqipWrOlyEkvHx44 .node polygon,#mermaid-svg-WqipWrOlyEkvHx44 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-WqipWrOlyEkvHx44 .rough-node .label text,#mermaid-svg-WqipWrOlyEkvHx44 .node .label text,#mermaid-svg-WqipWrOlyEkvHx44 .image-shape .label,#mermaid-svg-WqipWrOlyEkvHx44 .icon-shape .label{text-anchor:middle;}#mermaid-svg-WqipWrOlyEkvHx44 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-WqipWrOlyEkvHx44 .rough-node .label,#mermaid-svg-WqipWrOlyEkvHx44 .node .label,#mermaid-svg-WqipWrOlyEkvHx44 .image-shape .label,#mermaid-svg-WqipWrOlyEkvHx44 .icon-shape .label{text-align:center;}#mermaid-svg-WqipWrOlyEkvHx44 .node.clickable{cursor:pointer;}#mermaid-svg-WqipWrOlyEkvHx44 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-WqipWrOlyEkvHx44 .arrowheadPath{fill:#333333;}#mermaid-svg-WqipWrOlyEkvHx44 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-WqipWrOlyEkvHx44 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-WqipWrOlyEkvHx44 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WqipWrOlyEkvHx44 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-WqipWrOlyEkvHx44 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WqipWrOlyEkvHx44 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-WqipWrOlyEkvHx44 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-WqipWrOlyEkvHx44 .cluster text{fill:#333;}#mermaid-svg-WqipWrOlyEkvHx44 .cluster span{color:#333;}#mermaid-svg-WqipWrOlyEkvHx44 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-WqipWrOlyEkvHx44 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-WqipWrOlyEkvHx44 rect.text{fill:none;stroke-width:0;}#mermaid-svg-WqipWrOlyEkvHx44 .icon-shape,#mermaid-svg-WqipWrOlyEkvHx44 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WqipWrOlyEkvHx44 .icon-shape p,#mermaid-svg-WqipWrOlyEkvHx44 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-WqipWrOlyEkvHx44 .icon-shape .label rect,#mermaid-svg-WqipWrOlyEkvHx44 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WqipWrOlyEkvHx44 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-WqipWrOlyEkvHx44 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-WqipWrOlyEkvHx44 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户点击 Cell 区域
隐藏 TextInput 获取焦点
focusable + defaultFocus
系统键盘弹出
用户输入数字
verifyCode 更新
ForEach 重新渲染 6 个 Cell
三态逻辑
2.2 隐藏输入框
typescript
TextInput({ text: $$this.verifyCode })
.type(InputType.Number)
.maxLength(6)
.fontSize(0) // ← 文字大小为 0,实际上"不可见"
.caretColor(Color.Transparent) // ← 光标也透明
.backgroundColor(Color.Transparent)
.width('100%')
.height(0) // ← 高度为 0,不占布局空间
.opacity(0) // ← 完全透明
.focusable(true)
为什么不用
visibility: Hidden?visibility隐藏后组件无法获取焦点,无法触发系统键盘。用opacity: 0+height: 0是兼顾"键盘可弹出"和"视觉不可见"的折中方案。
2.3 6 个 Cell 的三态渲染
typescript
@Builder
VerifyCodeCells() {
Row({ space: 10 }) {
ForEach([0, 1, 2, 3, 4, 5], (index: number) => {
Stack() {
// 背景格子
Column()
.width(44)
.height(56)
.borderRadius(8)
.backgroundColor(AppColors.CARD)
.border({
width: 2,
color: index === this.verifyCode.length
? AppColors.PRIMARY // 当前位:主色高亮
: AppColors.BORDER // 其他位:默认灰色
})
// 三态内容渲染
if (index < this.verifyCode.length) {
// 状态 ①:已输入数字
Text(this.verifyCode[index])
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor(AppColors.TEXT)
} else if (index === this.verifyCode.length) {
// 状态 ②:当前输入位,显示光标
Column()
.width(2)
.height(24)
.backgroundColor(AppColors.PRIMARY)
.animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
}
// 状态 ③:未到达的位,什么都不显示(空格子)
}
})
}
.onClick(() => {
// 点击 Cell 区域,触发隐藏 TextInput 获取焦点
this.codeFocus = true
})
}
2.4 状态流转图
| Cell 索引 i | 条件 | 显示内容 | 边框颜色 |
|---|---|---|---|
i < length |
已输入 | 对应数字字符 | 默认灰色 |
i === length |
当前位 | 闪烁光标(2×24 竖线) | 主色高亮 |
i > length |
未到达 | 空(空格子) | 默认灰色 |
三、60 秒倒计时实现
3.1 启动倒计时
typescript
private startCountdown(): void {
this.isCountingDown = true
this.countdown = 60
this.countdownTimer = setInterval(() => {
this.countdown -= 1
if (this.countdown <= 0) {
this.stopCountdown()
}
}, 1000)
}
private stopCountdown(): void {
if (this.countdownTimer !== -1) {
clearInterval(this.countdownTimer)
this.countdownTimer = -1
}
this.isCountingDown = false
this.countdown = 60
}
3.2 按钮的两种状态
typescript
Button(this.isCountingDown
? `${this.countdown}s 后重发` // 倒计时中:显示剩余秒数
: '获取验证码' // 倒计时结束:可重新发送
)
.enabled(!this.isCountingDown && this.isPhoneValid())
.backgroundColor(
(!this.isCountingDown && this.isPhoneValid())
? AppColors.PRIMARY
: AppColors.TEXT_3
)
.animation({ duration: 250, curve: Curve.EaseOut })
.onClick(() => {
if (!this.isCountingDown && this.isPhoneValid()) {
this.sendVerifyCode()
}
})
3.3 组件销毁时清理定时器
typescript
aboutToDisappear(): void {
this.stopCountdown() // ← 防止页面销毁后 setInterval 仍在执行
}
内存泄漏风险 :如果不在
aboutToDisappear中clearInterval,即使页面已销毁,计时器仍会每秒触发setState,导致"更新已销毁的组件"异常。这是setInterval使用的必须注意事项。
四、表单校验设计
4.1 正则校验函数
typescript
private isPhoneValid(): boolean {
return /^1[3-9]\d{9}$/.test(this.phoneNumber)
}
private isCodeValid(): boolean {
return /^\d{6}$/.test(this.verifyCode)
}
| 正则 | 含义 | 示例 |
|---|---|---|
^1[3-9]\d{9}$ |
以 1 开头,第二位 3-9,共 11 位 | 13812345678 ✓ |
^\d{6}$ |
恰好 6 位纯数字 | 123456 ✓ |
4.2 登录按钮联合控制
登录按钮需要手机号和验证码同时有效才可用:
typescript
Button('登录')
.enabled(this.isPhoneValid() && this.isCodeValid() && !this.isLoggingIn)
.opacity(
(this.isPhoneValid() && this.isCodeValid()) ? 1.0 : 0.5
)
.animation({ duration: 300 })
.opacity(0.5) 而非 .backgroundColor 变色------视觉上"变暗变淡"的方式对用户更友好,不会让他们以为按钮"消失了"。
五、输入框聚焦高亮
5.1 手机号输入框聚焦
typescript
TextInput({ placeholder: '请输入手机号' })
.type(InputType.PhoneNumber)
.maxLength(11)
.border({
width: 1.5,
color: this.phoneFocus ? AppColors.PRIMARY : AppColors.BORDER
})
.animation({ duration: 250, curve: Curve.EaseOut })
.onFocus(() => { this.phoneFocus = true })
.onBlur(() => { this.phoneFocus = false })
聚焦时边框颜色从 BORDER(灰色)过渡到 PRIMARY(棕色),.animation 让颜色变化有 250ms 的平滑过渡。
六、调试模式与生产模式
6.1 发送验证码(双模式)
typescript
private async sendVerifyCode(): Promise<void> {
try {
// ===== 生产代码(预留)=====
// import { cloudSms } from '@hmscore/sms'
// await cloudSms.sendVerificationCode(this.phoneNumber)
// ===== 调试代码(当前运行)=====
await new Promise<void>(resolve => setTimeout(resolve, 800))
hilog.info(0x0000, TAG, '调试模式:验证码已"发送"到 %{private}s', this.phoneNumber)
this.startCountdown()
promptAction.showToast({ message: '验证码已发送(调试:任意6位数字均可)' })
} catch (e) {
promptAction.showToast({ message: '发送失败,请重试' })
}
}
注意手机号日志安全 :
hilog.info中手机号使用%{private}s而非%{public}s,确保手机号在 Release 构建的日志中被隐藏。
6.2 验证码校验(双模式)
typescript
private async handleLogin(): Promise<void> {
this.isLoggingIn = true
try {
// 生产:
// const result = await cloudSms.verifyCode(this.phoneNumber, this.verifyCode)
// if (!result.success) throw new Error('验证码错误')
// 调试:任何 6 位数字均视为正确
await new Promise<void>(resolve => setTimeout(resolve, 600))
await this.initUserAfterLogin(
'PHONE_' + this.phoneNumber.slice(-4),
'鹿鹿用户'
)
} finally {
this.isLoggingIn = false
}
}
七、常见问题
7.1 键盘弹出后 Cell 被遮挡
现象:输入验证码时,软键盘弹出后 6 个 Cell 消失在键盘下方
解决 :在根 Column 外套 Scroll,并设置 .padding({ bottom: 300 }),让内容区有足够的滚动空间:
typescript
Scroll() {
Column() {
// ... 所有内容
}
.padding({ bottom: 300 })
}
7.2 光标动画不闪烁
现象:当前 Cell 显示的光标不闪烁,只是一条静止的竖线
解决 :光标需要 iterations: -1(无限循环)+ playMode: PlayMode.Alternate(来回播放):
typescript
Column()
.width(2).height(24)
.backgroundColor(AppColors.PRIMARY)
.animation({
duration: 600,
iterations: -1,
playMode: PlayMode.Alternate,
curve: Curve.Linear
})
.opacity(1) // 通过 opacity 来回 0→1 实现闪烁效果
八、注意事项与常见问题
8.1 开发注意事项
在实际开发过程中,需特别注意以下几点:
- API 兼容性:部分接口仅在特定 HarmonyOS NEXT 版本中可用,需做版本条件判断
- 权限模型:采用静态声明(module.json5)+ 动态申请(requestPermissionsFromUser)的两阶段授权
- 生命周期 :合理使用
aboutToAppear()和aboutToDisappear()管理资源初始化与释放 - 状态同步 :跨页面数据通过 AppStorage 共享,组件内状态使用
@State/@Prop/@Link装饰器
8.2 常见错误与解决方案
开发过程中的高频问题汇总:
| 错误现象 | 可能原因 | 解决方案 |
|---|---|---|
| 权限被系统拒绝 | 未在 module.json5 中静态声明 |
添加 requestPermissions 权限配置项 |
| 页面跳转后白屏 | 路由路径未在 main_pages.json 注册 |
检查并补充路由配置文件 |
| UI 状态不刷新 | 状态变量未添加响应式装饰器 | 为变量加 @State / @Observed 装饰器 |
| 数据库查询返回空 | 查询条件与存储数据格式不匹配 | 使用 hilog.debug 打印 SQL 条件排查 |
| 相机预览黑屏 | 运行时相机权限未获取 | 先调用 requestPermissionsFromUser 申请 |
总结
PhoneLoginPage 的 514 行代码的核心亮点:
- 隐藏输入框 + 自定义 Cell 组合:最优雅的 ArkUI 原生 6 位验证码实现方案
- 三态 Cell 渲染 :
i < len/i === len/i > len三种分支,代码清晰逻辑无歧义 - setInterval + aboutToDisappear 联动:计时器必须在页面销毁时清理
- 双输入联合校验:手机号正则 + 验证码正则 AND 组合控制按钮状态
%{private}s手机号隐私:生产日志不暴露用户隐私信息
下一篇预告 :第91篇 AnalyzingPage------AI 分析数据流水线与五步进度动画
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!
相关资源:
- TextInput API 文档
- Timer(setInterval)API
- HMS Cloud SMS 服务
- animateTo 显式动画
- ForEach 列表渲染
- Button 组件 API
- 第89篇 LoginPage Canvas 插画
- 第88篇 UserDao 用户持久化
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!
PhoneLoginPage 技术亮点 :在没有系统级 OtpInput 组件的 ArkUI 中,通过 ForEach 渲染 6 个独立 TextInput + 焦点自动跳转 + 键盘监听,手工实现了媲美原生的 PIN 码输入体验。
关键技巧总结:
- 验证码框:
TextInput({ placeholder: '' }).maxLength(1)× 6 - 焦点自动跳转:
focusControl.requestFocus('pin_${index + 1}') - 60 秒倒计时:
setInterval+@State countdown驱动 UI 更新 - 防抖提交:
isSubmitting标志位防止重复触发验证逻辑