HarmonyOS ArkUI focusControl:发布自检中弹窗关闭后的键盘焦点归位【鸿蒙心迹】
页面看起来能点,不代表它对键盘用户也完整。一个检查单的条款提示打开后,用户可以用触屏点关闭;但是接上物理键盘,关闭按钮消失以后,下一次按 Tab 究竟会落到哪里?如果焦点回到页面顶部,用户不得不重新穿过一整串表单控件;如果焦点留在已经删除的弹窗子树,光看屏幕可能根本发现不了问题。这里选用一个很小的发布前自检页面,专门说明弹窗结束时如何安排焦点,而不是假装一次界面截图就能证明无障碍合规。

一、先界定自检目标,再谈提交审核
Demo 叫 FocusRoute,检查单编号 ACC-317。主页面有“查看条款”“继续填写”“提交检查单”三个业务入口;打开条款后出现“关闭条款”按钮。预期操作路径是先聚焦“查看条款”,进入条款层,再关闭条款,最后将键盘操作位置送回“继续填写”。最终用于展示的应用级状态文本是 TERMS_CLOSED → FOCUS_RESTORED,这里两个英文状态由 Demo 自己定义,不是系统焦点枚举。
标题中的“发布自检”指开发团队在提交前执行的交互回归检查,不是承诺通过应用市场审核。公开文档能够证明 ArkUI 存在焦点控制和获焦事件,却不能替某个应用决定所有审核结论。应用还需要结合目标设备类型、读屏模式、键盘类型、对话框形式与实际审查要求分别验证。把“页面看起来没问题”换成可观察、可复现的检查步骤,才是本文的范围。
一个关键选择是:这个示例使用条件渲染的应用内条款层,不是系统 AlertDialog 或单独的窗口。前者与“继续填写”在同一个 ArkUI 组件树中,便于说明 .id() 和焦点回归;如果产品采用系统弹窗、UIExtension 或多窗口,焦点范围和时机都可能不同,不能不加修改就照搬示例。为了避免混淆,技术配图也只画应用内条款层和主页面的状态,不把它标注成系统级对话框。
二、触屏能关闭,与键盘能继续,是两条不同的验收线
焦点回归要先定义业务上“该回哪里”。最简单的错误做法是关闭弹窗后固定聚焦第一个按钮。对于长表单,这通常让用户丢失操作上下文。另一个错误是把上一个焦点索引记成数字,靠遍历按钮数量来恢复;页面增删一个控件、按钮变灰或者某项因为权限隐藏后,索引就可能指向另一项。
本例明确把回归点命名为 continueBtn,采用组件稳定的 .id('continueBtn') 来定位。下面先定义检查单数据和预期序列,供复测脚本或纯逻辑断言使用。状态名不是 ArkUI API,都是业务层可调整的常量。
export interface FocusAuditCase {
ticket: string;
triggerId: string;
returnId: string;
expected: string[];
}
export const focusAuditCase: FocusAuditCase = {
ticket: 'ACC-317',
triggerId: 'termsOpenBtn',
returnId: 'continueBtn',
expected: ['TERMS_OPEN', 'TERMS_CLOSED', 'FOCUS_REQUESTED', 'FOCUS_RESTORED']
};
triggerId 是用户进入条款的入口;returnId 是关闭后的目标控件,而不是“随机挑一个当前可见按钮”。把期望序列保存在普通数据对象里,可以让测试人员明确区分四个时点。尤其要注意,FOCUS_REQUESTED 不代表成功获焦。请求接口返回的布尔值表达是否接受申请;真正应该由目标控件的 onFocus 来更新 FOCUS_RESTORED。若跳过后者,日志中“已恢复焦点”就只是一句乐观的猜测。
真实产品还要考虑“继续填写”是否因为表单条件而禁用。如果目标按钮不可获焦,不能死循环提交同一个申请。更合适的方式是重新选一个有业务意义的邻近控件,并在测试步骤里写清楚回退规则。本示例为了聚焦单一问题,假设“继续填写”可见且可获焦。
三、先移除条款层,再发出焦点申请
ArkUI 官方焦点资料给出 .focusable()、.id()、focusControl.requestFocus()、.onFocus() 等接口,并明确全局 requestFocus 的生效与渲染帧有关;文档也提示多 UI 实例场景要考虑 UIContext。这里选择条件渲染关掉条款层,再向同一页面内的目标按钮申请焦点。它不是跨窗口恢复焦点方案,不需要伪造更复杂的控制器 API。
页面逻辑如下。条款层打开时遮住表单的主要操作区;点击关闭时先把 showTerms 置为 false,然后调用 focusControl.requestFocus('continueBtn')。示例将日志分成“关闭”“申请”“获焦”三种事件,以免一个 request 请求就被误认为完整成功。
@Entry
@Component
struct FocusRoutePage {
@State private showTerms: boolean = false;
@State private auditStatus: string = 'IDLE';
closeTerms(): void {
this.showTerms = false;
this.auditStatus = 'TERMS_CLOSED';
console.info('FocusRoute ACC-317 TERMS_CLOSED');
const accepted: boolean = focusControl.requestFocus('continueBtn');
if (accepted) {
this.auditStatus = 'FOCUS_REQUESTED';
console.info('FocusRoute ACC-317 FOCUS_REQUESTED target=continueBtn');
} else {
this.auditStatus = 'REQUEST_FAILED';
}
}
build() {
Stack() {
Column({ space: 18 }) {
Text('ACC-317 · 焦点回归检查').fontSize(24).fontWeight(FontWeight.Bold)
Button('查看条款').id('termsOpenBtn').enabled(!this.showTerms).onClick(() => {
this.showTerms = true;
this.auditStatus = 'TERMS_OPEN';
})
Button('继续填写').id('continueBtn').focusable(!this.showTerms)
.onFocus(() => {
this.auditStatus = 'FOCUS_RESTORED';
console.info('FocusRoute ACC-317 FOCUS_RESTORED target=continueBtn');
})
Button('提交检查单').enabled(!this.showTerms)
Text(`状态:${this.auditStatus}`)
}.padding(24).width('100%')
if (this.showTerms) {
Column({ space: 12 }) {
Text('条款提示 · 阅读后可以返回原操作位置')
Button('关闭条款').id('termsCloseBtn').onClick(() => this.closeTerms())
}.padding(18).width('100%').height('100%').backgroundColor('#F1F4FF')
}
}.width('100%').height('100%')
}
}
这里有一个容易忽略的语义边界:closeTerms() 中更新状态后发起焦点请求,能否准确命中目标还受组件树更新时间、窗口焦点、控件可见性及实际设备的键盘事件影响。如果申请失败,应显示 REQUEST_FAILED 并观察组件是否仍然挂载。更复杂的项目可以借助 this.getUIContext().getFocusController() 绑定明确的 UI 实例,但它的请求时机与全局接口不同,不能只替换名字而不复测。
onFocus 中的日志才是本示例判断焦点真正进入目标组件的可观察事件。官方资料特别提示焦点事件的实际效果需在真机环境确认,所以这里没有填入“模拟器跑通”或“硬件键盘通过”的虚构结论。图片里的焦点框以及日志只是对预期流程的可视化排版,使用者要在目标设备上按顺序执行后才能把它变成验收证据。

四、让错误路径在发布前能被故意触发
自检工具不能只会输出绿灯。若研发只在“正常关闭一次条款”时观察 FOCUS_RESTORED,很容易漏掉按钮被置为不可获焦、条款尚未移除、窗体失去焦点以及业务控件动态隐藏的情况。建议至少做四类操作:连续打开关闭两次,关闭后立刻按 Tab,动态禁用返回目标,以及使用键盘方向键在相邻按钮间切换。它们分别验证重复操作的幂等性、走焦顺序、失败处理和可达性。
为避免把日志内容当成主观判断,可使用一个不依赖 UI 的小函数对构造事件列表做逻辑核对。这个函数不负责采集 HiLog,更不会读取屏幕读者信息;它只接受测试人员或测试框架整理好的事件顺序。真实执行的 onFocus 触发情况仍由运行环境决定。
export function auditFocusTrace(events: string[], target: string): string {
const closed = events.indexOf('TERMS_CLOSED');
const requested = events.indexOf(`FOCUS_REQUESTED:${target}`);
const restored = events.indexOf(`FOCUS_RESTORED:${target}`);
if (closed < 0) return 'MISSING_CLOSE';
if (requested < 0 || requested <= closed) return 'MISSING_REQUEST';
if (restored < 0 || restored <= requested) return 'MISSING_FOCUS_EVENT';
return 'PASS';
}
const sample = ['TERMS_OPEN', 'TERMS_CLOSED',
'FOCUS_REQUESTED:continueBtn', 'FOCUS_RESTORED:continueBtn'];
console.info(`FocusRoute ACC-317 TRACE=${auditFocusTrace(sample, 'continueBtn')}`);
auditFocusTrace 的 PASS 表示输入数组符合这条模拟事件序列,不等于系统无障碍审核 PASS。关键是先检查关闭事件,再检查目标申请,再检查目标真实获焦事件,三者次序不可颠倒。若删除最后一项,结果应是 MISSING_FOCUS_EVENT;这正是最常见的“界面没报错、焦点却不明”的盲区。函数只核对顺序,不证明焦点框在视觉上可见,也不能替代读屏语义测试。
开发图下方的 HiLog 区按预期排列 TERMS_CLOSED、FOCUS_REQUESTED target=continueBtn、FOCUS_RESTORED target=continueBtn。手机示意图显示条款层已关闭,蓝色焦点描边落在“继续填写”,状态为 FOCUS_RESTORED。这两张技术图各有作用:前者说明事件链和请求点,后者说明用户应当看到的最终操作位置,而不是两张图都重复代码。
五、验收标准必须包含环境,而不只有截图
建议验收记录写上设备形态、是否接入物理键盘、窗口是否在前台、是否开启读屏、条款层实现方式、当前按钮是否可见及哪一项真正产生了 onFocus。这些比单独贴一张“蓝色描边的按钮”更有价值。不同设备、输入法和无障碍服务可能改变焦点移动体验,因此本文不承诺某一套请求代码能覆盖所有场景。

还有一个实际风险:defaultFocus(true) 通常面向初次进入页面的默认焦点,不应被当作每次弹窗关闭后的无条件恢复指令。页面里若多个控件都带这个设置,第一次进入时的获焦结果还可能受组件树顺序影响。本例选择一次明确的 requestFocus,并由 onFocus 确认,是为了避免“初始化策略”与“交互恢复策略”互相掩盖。
如果条款层以后改成系统 Dialog、独立 UIExtension 或跨页面路由,焦点回归应重新制定集成策略,而不是把 continueBtn 这个 id 跨容器直接复用。正式项目还需要文本说明、控件语义、对比度、点击区域及读屏顺序等独立检查项;这篇文章只解决弹窗关闭以后,下一次键盘操作有没有明确且可观察的落点。
本轮的工程结论很克制:按钮关闭成功是一件事,焦点申请被接受是另一件事,目标组件真正触发 onFocus 又是第三件事。只有把它们拆开,调试日志才能说明一次操作从哪里开始、在哪一步断掉。示意图可以帮助团队统一验收预期,但不能代替对实际 HarmonyOS 设备和发布环境的测试。
参考资料: 华为 ArkUI 焦点控制参考;焦点事件官方说明;重新设置新焦点位置。
更多推荐


所有评论(0)