ArkUI FocusController:折叠屏搜索框焦点租约与键盘回收【鸿蒙心迹】
搜索框的麻烦,很多时候并不在搜索。页面上明明只有一个输入框,却会在折叠屏由双栏变成单栏时突然拉起软键盘;用户刚刚点击“完成”,键盘又冒出来;再返回详情页,光标仿佛还停留在旧的搜索框里。看似是焦点属性的小问题,实际牵涉到组件有没有挂载、窗口布局什么时候完成、谁有权发起下一次焦点请求。我把场景压到一个叫 FocusLeaseDemo 的阅读检索页。演示数据固定为任务 FOCUS_20261016_19,搜索词“山间书店”,当前布局 SPLIT,业务记录的焦点状态 RELEASED,键盘期望状态 HIDDEN,最后一次焦点请求代次 4。这不是对系统键盘的测量值,而是为了给状态迁移设计一条清楚、可复查的样例路径。
图 01:搜索框焦点与键盘回收的主题封面。折叠设备为概念绘制,不是设备运行截图。## 一、先区分焦点、键盘和业务输入状态当 TextInput 获焦,通常会引出输入法交互,但三个状态不能写成同一个布尔量。焦点是 ArkUI 节点的能力,软键盘来自输入法服务,搜索词则属于业务数据。仅仅把 @State isEditing 改成 false,并不等于系统已经把焦点移走。类似地,调用请求焦点接口后,也不能立刻声称键盘已经弹出。折叠屏切换还会重建节点,旧节点可能已经不在树上。我习惯给每一轮“尝试控制焦点”的动作一个代次。旧布局发起的请求,就不应该有资格影响新布局。这种写法并不是 HarmonyOS 新增了某个叫 FocusLease 的 API,而是用普通 ArkTS 对系统焦点请求设一道业务门禁。下面先写最小实现,解决旧布局的延迟请求不能重新夺回焦点的问题。arktsexport class FocusLease { private revision: number = 0 next(): number { this.revision += 1 return this.revision } invalidate(): void { this.revision += 1 } isCurrent(revision: number): boolean { return revision === this.revision } value(): number { return this.revision }}````revision` 是递增的会话标识,不是时间戳。它不负责判断节点是否可获焦,而是回答请求是否属于当前这次界面状态。切换布局、关闭搜索、退出页面时都应该使旧代次失效。不能在异常分支为了省事把它重置为零,否则旧请求编号可能与新会话碰撞。这个类也没有跨进程持久性,不适合拿来做业务身份凭据。## 二、焦点请求必须晚于组件挂载和布局稳定华为官方焦点文档给出了 `getUIContext().getFocusController().requestFocus()` 的用法,同时明确了请求目标组件未挂树、不可见或不可获焦时可能失败。这个事实决定了不能在所有生命周期回调里无条件“找回焦点”。需要先看页面是否仍然活着,再看当前是否应该进入编辑模式,最后才把请求送给当前 UIContext。为了让读者能看清核心关系,演示页面把“切换布局”做成了一个业务按钮。真实折叠屏工程可以在窗口尺寸断点变化时调用同一套业务动作,但不能把下面的按钮当成系统折叠状态 API。第二段代码解决**焦点状态由当前组件响应,布局切换只撤销旧请求**的问题。arktsimport { inputMethod } from ‘@kit.IMEKit’@Entry@Componentstruct FocusLeasePage { @State query: string = ‘山间书店’ @State layoutMode: string = ‘SPLIT’ @State focusState: string = ‘RELEASED’ private lease: FocusLease = new FocusLease() private restoreFocus(): void { const requestRevision = this.lease.next() if (!this.lease.isCurrent(requestRevision)) return try { this.getUIContext().getFocusController().requestFocus(‘searchInput’) console.info([FocusLease] focus request rev=${requestRevision}) } catch (err) { console.error([FocusLease] request failed: ${JSON.stringify(err)}) } } private releaseSearch(): void { this.lease.invalidate() try { this.getUIContext().getFocusController().requestFocus(‘finishButton’) inputMethod.getController().stopInputSession() } catch (err) { console.error([FocusLease] release failed: ${JSON.stringify(err)}) } } aboutToDisappear(): void { this.lease.invalidate() } build() { Column({ space: 12 }) { TextInput({ text: this.query, placeholder: ‘搜索内容’ }) .id(‘searchInput’) .onChange((value: string) => { this.query = value }) .onFocus(() => { this.focusState = ‘FOCUSED’ }) .onBlur(() => { this.focusState = ‘RELEASED’ }) Button(‘重新聚焦’).onClick(() => { this.restoreFocus() }) Button(‘完成输入’).id(‘finishButton’).focusable(true) .onClick(() => { this.releaseSearch() }) Button(‘切换布局’).onClick(() => { this.lease.invalidate() this.layoutMode = this.layoutMode === ‘SPLIT’ ? ‘COMPACT’ : ‘SPLIT’ }) Text(布局 ${this.layoutMode} · 焦点 ${this.focusState}) }.padding(16) }}````onFocus、onBlur更新的是当前节点收到的事件,不能直接推算整个软键盘的可见高度;代码中的stopInputSession()是调用输入法控制器结束输入会话,生产环境仍应确认退出输入场景后的实际反馈。这里用.id(‘searchInput’) 把目标明确挂到节点上,再从当前 UIContext 请求,避免操作一个已经切走的上下文。上面保留了一处工程上值得留意的限制:requestFocus() 是同步发起的调用,但页面是否成功进入期望交互状态需要事件再确认。restoreFocus日志应该叫“request”,不能改成“focused success”。另外,组件刚刚因为断点变化重新挂载时,需要等新节点真正出现以后再请求;实际项目可由onAppear或受控状态通知触发,而不是套一个任意延时。*图 02:白色 DevEco Studio 风格演示图,显示FocusLeasePage.ets的焦点控制逻辑和示意 HiLog。右侧仅演示布局SPLIT、焦点 RELEASED,不代表系统键盘真实检测。*## 三、收起以后又弹出,先查代次而不是先调动画折叠屏上容易出现这样的时间顺序:在单栏里输入关键词,触发一次自动聚焦;用户紧接着展开设备,旧节点被替换;用户选择完成输入;旧布局留下的异步业务动作随后再发一次请求。如果没有请求所有权判断,就会产生“我已经关闭键盘,它却又弹出来”的错觉。测试这个问题不必每次都去真实折叠设备上反复折叠。先用普通数据构造器列出可判定的时序:请求代次 2之后发生布局切换,代次进入3;执行“完成输入”后进入 4,那么任何来自 2和3 的后续请求都应视为过期。第三段代码负责验证这种业务判断,不依赖系统焦点事件。```arktsconst lease = new FocusLease()const oldRequest = lease.next() // 1:旧布局请求lease.invalidate() // 2:布局已切换const newRequest = lease.next() // 3:新布局请求lease.invalidate() // 4:用户完成输入console.info([FocusLease] old=lease.isCurrent(oldRequest)‘)console.info(‘[FocusLease]new={lease.isCurrent(oldRequest)}`)console.info(`[FocusLease] new=lease.isCurrent(oldRequest)‘)console.info(‘[FocusLease]new={lease.isCurrent(newRequest)})console.info([FocusLease] revision=${lease.value()})// 预期:old=false,new=false,revision=4```这只是确定性输入向量的**预期输出**,不是真机 HiLog。代码刻意保留 old和new两次检查,是要提醒开发者:过期的不一定是最早的请求,新布局的请求也可能被用户紧接着的退出动作取消。把这类业务判断放在接口调用之前,才能避免大部分无意义的焦点申请。## 四、图中为什么仍然展示“山间书店”焦点收回并不意味着清空检索关键词。用户进入某篇详情后返回列表,往往期待搜索结果仍在,只是不希望键盘继续占据半屏。所以我把业务关键词保留为“山间书店”,只更新焦点与输入会话相关状态。演示终态是SPLIT / RELEASED / HIDDEN,租约代次为 4,搜索词原样保留。*图 03:纯手机界面演示。任务编号 FOCUS_20261016_19、布局 SPLIT、焦点 RELEASED、键盘期望 HIDDEN与正文相同。界面为设计稿,非真机录屏。*如果按这套思路联调,我会把实际观测拆成四列:请求发生时的节点 ID、布局断点、业务代次,以及随后真正收到的onFocus/onBlur。键盘是否可见、占用了多高的窗口,则使用系统输入法和窗口的实际反馈进一步核查,而不是靠业务变量取巧。这样一条日志就能解释是“请求没生效”“节点已卸载”还是“输入法会话还没结束”,排查路径比单纯调边距清楚得多。## 五、实际使用要守住的边界这套设计适合折叠屏阅读器、平板检索页、多窗口表单等场景,核心收益是把焦点发起权明确绑定到当前界面生命周期。它不适合处理真实键盘高度计算、输入法候选栏动画或系统级窗口焦点权限。UIContext 的选择、组件是否挂树、动态重建后的请求时机仍需在目标设备验证。我不会因为演示图里写了 HIDDEN` 就断言物理键盘或软键盘已经收起;实际 App 还要面对外接键盘、语音输入、辅助技术以及多窗口环境。把“我们希望什么”和“系统实际上发生了什么”分开记录,才是避免焦点问题反复出现的关键。还有一个需要在产品评审时提前说明的点:同一搜索栏可能同时服务鼠标、触屏、实体键盘和屏幕朗读。不能为了“总是收起键盘”就一概把输入组件设置为不可获焦,否则外接键盘的 Tab 导航与无障碍操作会受到影响。更稳妥的做法是由明确的用户动作触发释放,例如提交检索、关闭面板或离开页面;而窗口仅仅改变宽度时,默认尽量保持关键词和输入意图。必要时让用户主动点“重新聚焦”,而不是悄悄替他打开键盘。官方资料:华为开发者联盟《焦点控制》API 参考、ArkUI FAQ《如何动态控制键盘绑定在不同的 TextInput 上》与《实现 Text 和 TextInput 可切换的堆叠布局》。示例已核对接口名称,但未在 DevEco 编译或实机运行。
更多推荐


所有评论(0)