HarmonyOS 7 + 平行视界 + FocusController:双栏搜索与详情编辑的焦点归属及键盘收口【鸿蒙心迹】
平行视界把列表与详情同时放在大屏上之后,页面跳转少了,焦点问题却更容易暴露:左栏搜索框还握着输入焦点,右栏笔记框已经出现;用户点进右栏,键盘候选词却继续写到左栏;详情被替换以后,已经销毁的输入框还在等待下一帧获焦。本文用演示工程 DualFocus Desk 拆解这条交互链。所有状态次数均为固定演示数据,不冒充真机实测。

一、双栏可见,不等于双栏都能编辑
HarmonyOS 7 平行视界面向折叠屏、平板等大屏场景,提供左右推挤与固定覆盖等分栏体验。官方示例以 Navigation 路由承载购物类应用,并覆盖主页、关联页、过渡页、全屏与应用内分屏等典型场景。系统能力解决了页面如何并列呈现,但具体业务仍要决定:当前哪一个输入框可以接收文字,切换详情时旧编辑状态如何结束,页面离开时键盘由谁收口。
DualFocus Desk 是一个资料检索 Demo。左栏叫“资料目录”,包含搜索框 catalogSearch;右栏叫“详情笔记”,包含编辑框 detailNote。任务编号固定为 FOCUS-2816,平行视界模式标记为 PUSH。演示状态按以下顺序流转:
LEFT_SEARCH → DETAIL_READY → NOTE_EDITING → RELEASED
最终诊断结果是“焦点切换 3 次 / 键盘残留 0”。这里的 3 次并非系统性能指标,只是本文用来核对图片、日志与正文的一组样本:第一次进入左栏搜索,第二次选择资料后把焦点交给右栏笔记,第三次返回搜索;离开页面时进入 RELEASED,不再保留编辑会话。
问题的根源不是 TextInput 数量,而是焦点请求并非当前帧立即生效。官方焦点控制文档说明,requestFocus() 在下一帧生效,并建议通过 UIContext 获取绑定实例的 FocusController。如果代码在同一轮里先请求右栏焦点、又因为列表刷新请求左栏焦点,最终结果取决于后一次调度,而不是开发者主观认为的调用顺序。
软键盘又是焦点的外显结果。输入框获焦会带起键盘,输入结束时可以通过 TextInputController.stopEditing() 收起编辑会话。于是工程上需要管理的并非一个布尔值 isKeyboardShown,而是“焦点所有者、请求代次、目标组件是否仍存在、退出时是否已经停止编辑”四件事。
二、先把焦点归属做成状态机
这段代码解决什么问题:把左右栏焦点切换从零散回调收拢为可验证状态,并用代次拒绝过时请求。
type FocusOwner =
'NONE' | 'LEFT_SEARCH' | 'DETAIL_READY' | 'NOTE_EDITING' | 'RELEASED';
interface FocusIntent {
seq: number;
owner: FocusOwner;
key?: 'catalogSearch' | 'detailNote';
reason: string;
}
export class FocusCoordinator {
private seq: number = 0;
private owner: FocusOwner = 'NONE';
next(owner: FocusOwner, key: FocusIntent['key'], reason: string): FocusIntent {
this.owner = owner;
return { seq: ++this.seq, owner, key, reason };
}
isCurrent(intent: FocusIntent): boolean {
return intent.seq === this.seq && this.owner !== 'RELEASED';
}
release(): FocusIntent {
this.owner = 'RELEASED';
return { seq: ++this.seq, owner: 'RELEASED', reason: 'page_disappear' };
}
}
这段代码不直接调用 ArkUI,而是只记录意图。好处是路由、列表选择和输入事件不会各自抢着改焦点。每个请求都带 seq,页面在下一帧真正执行前还要确认它仍是最新意图。详情 A 被详情 B 替换时,A 发出的旧请求即使晚到,也无法把焦点拉回已经不存在的组件。
DETAIL_READY 特意不等于 NOTE_EDITING。用户选择条目后,右栏详情出现,但不应该自动弹起键盘打断阅读;只有明确点进笔记框,才进入编辑状态。把“页面可见”和“输入激活”拆开,是平行视界里很重要的产品取舍。
状态机还给日志提供了稳定词汇。相比打印“准备切一下焦点”,task=FOCUS-2816 seq=2 owner=DETAIL_READY 更容易与截图和故障报告对齐。真正接入项目时,可以把状态保存在页面 ViewModel,而不是全局共享;多个平行视界实例同时存在时,焦点域必须按窗口或页面实例隔离。
三、请求焦点之前,确认目标仍在树上
这段代码解决什么问题:通过当前 UIContext 的 FocusController 请求指定输入框获焦,并在下一帧执行前校验请求代次。
@Entry
@Component
struct DualFocusPage {
@State owner: FocusOwner = 'LEFT_SEARCH';
@State selectedId: string = '';
private focusCoordinator: FocusCoordinator = new FocusCoordinator();
private noteController: TextInputController = new TextInputController();
private requestOwner(owner: FocusOwner,
key: 'catalogSearch' | 'detailNote', reason: string): void {
const intent = this.focusCoordinator.next(owner, key, reason);
setTimeout(() => {
if (!this.focusCoordinator.isCurrent(intent)) return;
const ok = this.getUIContext()
.getFocusController()
.requestFocus(key);
if (ok) this.owner = owner;
console.info('[DualFocus] task=FOCUS-2816 seq=' +
intent.seq + ' owner=' + owner + ' ok=' + ok);
}, 0);
}
private openDetail(id: string): void {
this.selectedId = id;
this.noteController.stopEditing();
this.focusCoordinator.next('DETAIL_READY', undefined, 'detail_changed');
this.owner = 'DETAIL_READY';
}
}
这里用零延时任务只是把请求放到组件更新之后,不代表所有项目都应该复制 setTimeout(0)。更稳的实现可以在右栏目标组件 onAppear 后提交请求,或者由状态模型在确认目标挂载后调用。无论采用哪种方式,都要在执行点再次校验代次。
requestFocus() 返回布尔结果,但返回成功不等于业务已经安全完成。目标可能在下一轮路由切换中立即销毁,键盘也可能因页面离开而收起。因此日志既要记请求结果,也要记随后发生的 RELEASED。如果只记录 ok=true,仍然解释不了“键盘闪了一下又消失”。

图 02 是与本文字段一致的演示配图,不是真实 IDE 截图。左侧工程目录包含 FocusCoordinator.ets、DualFocusPage.ets,中间标出 getFocusController().requestFocus() 与 stopEditing(),右侧模拟器固定显示 FOCUS-2816 和 NOTE_EDITING,底部日志呈现四段状态流转。
四、输入框自身只负责上报,不负责跨栏调度
这段代码解决什么问题:让左右栏输入组件声明稳定 key,上报用户意图,并在离开页面时成对停止编辑和释放焦点状态。
build() {
Row() {
Column({ space: 12 }) {
Text('资料目录').fontSize(22).fontWeight(FontWeight.Bold)
TextInput({ placeholder: '搜索资料' })
.key('catalogSearch')
.onFocus(() => {
this.requestOwner('LEFT_SEARCH', 'catalogSearch', 'user_search');
})
List() {
ListItem() {
Text('ArkUI 焦点控制').onClick(() => this.openDetail('DOC-17'))
}
}
}.width('42%')
Divider().vertical(true)
Column({ space: 12 }) {
Text('详情笔记').fontSize(22).fontWeight(FontWeight.Bold)
Text(this.selectedId || '请选择资料')
TextInput({ placeholder: '记录要点', controller: this.noteController })
.key('detailNote')
.enabled(this.selectedId.length > 0)
.onFocus(() => {
this.requestOwner('NOTE_EDITING', 'detailNote', 'user_note');
})
}.layoutWeight(1)
}.width('100%').height('100%')
}
aboutToDisappear(): void {
this.noteController.stopEditing();
const released = this.focusCoordinator.release();
this.owner = released.owner;
console.info('[DualFocus] task=FOCUS-2816 owner=RELEASED');
}
左右输入框的 onFocus 只描述用户行为,真正的协调仍经过 requestOwner()。这避免左栏组件直接操作右栏控制器,也避免右栏知道列表路由细节。页面退出时,stopEditing() 与 release() 成对执行:一个结束输入会话,一个让所有晚到焦点请求失效。
容易出错的地方是递归。requestOwner() 调用 requestFocus() 后,目标的 onFocus 又调用 requestOwner(),可能生成新代次。实际项目应增加“当前所有者已是目标则不再请求”的幂等判断,或把用户触发与程序触发分成两个入口。示例为保持代码段可读,省略了这一层守卫,但正文必须明确风险。
另一个边界是详情为空。没有选中资料时,右栏输入框禁用;程序不能请求一个不可获焦组件。列表切换期间如果详情先卸载后挂载,要把状态停在 DETAIL_READY,等新笔记框出现再允许用户进入 NOTE_EDITING。
五、手机态先验证单栏逻辑,再看大屏双栏
平行视界主要服务大屏,但同一业务通常也要运行在手机单栏。手机态点击资料后进入详情页,焦点规则仍然成立:进入详情不自动编辑,用户点笔记框才拉起键盘,返回列表前先结束输入。先把单栏生命周期跑通,再让系统把列表与详情并列,问题更容易定位。

图 03 展示手机运行态,时间 18:26,页面为“资料目录”,任务 FOCUS-2816,当前所有者 LEFT_SEARCH,目标 key 为 catalogSearch。状态栏包含 Wi-Fi、5G、信号和电量数字;红色箭头只解释当前焦点,不承担装饰作用。
当窗口进入平行视界,左栏列表仍然可见,右栏详情在同一屏出现。此时返回行为不应该简单等同于销毁整个页面:如果右栏正在编辑,先结束编辑;如果只是查看详情,可以按业务策略关闭右栏或回退关联页。焦点协调器只给出输入边界,具体路由由 Navigation 与平行视界配置决定。
六、详情页要记录“谁把焦点拿走了”
偶现键盘串栏不能靠最终截图定位。诊断页至少要记录请求序号、来源、目标 key、目标是否存在、requestFocus() 返回值、旧编辑是否已经停止,以及页面最终状态。DualFocus Desk 固定展示三次切换:
- seq=1:进入 catalogSearch,状态 LEFT_SEARCH。
- seq=2:选择 DOC-17,状态 DETAIL_READY,不弹键盘。
- seq=3:点击 detailNote,状态 NOTE_EDITING。
- 页面退出:状态 RELEASED,键盘残留计数为 0。

图 04 与运行总览明显不同,时间为 18:27,重点展示 LEFT_SEARCH → DETAIL_READY → NOTE_EDITING → RELEASED 和“焦点切换 3 次 / 键盘残留 0”。红圈标出 seq=3 detailNote,红箭头指向最终释放结果。它是技术解释图,不是系统日志证据。
如果日志显示 requestFocus=true,随后所有者仍是左栏,先查 onFocus 是否递归生成了新意图;如果详情替换后旧 key 再次出现,查页面实例是否复用了相同协调器;如果退出后仍弹键盘,查是否只调用了 release() 而未执行 stopEditing();如果手机正常、大屏异常,查两个可见页面是否共用了全局焦点状态。
七、键盘收口不是一行 hide
直接调用隐藏键盘接口看起来省事,却可能掩盖输入框仍持有焦点。下一次窗口变化或组件重建后,键盘又会弹出。stopEditing() 的价值是结束控制器对应的编辑会话;焦点状态机的价值是让后续旧请求无效。两者解决不同问题,不能相互替代。
页面切到后台时是否释放,要看产品需求。长文编辑器可能希望回来继续输入,资料检索页则更适合结束键盘,避免恢复时遮挡内容。无论选哪种策略,都要把前后台切换写进状态机,而不是散落在多个生命周期回调里。
自定义键盘场景还要保存光标位置。官方自定义键盘指南给出通过 TextInputController 设置光标、通过 stopEditing() 结束编辑的做法。平行视界里切换详情时,草稿与光标都应绑定资料 ID;不能只保存一个全局数字,否则从 DOC-17 切到 DOC-18 会把光标位置带错。
多窗口场景下也不要使用全局 focusControl 猜当前实例。官方文档建议从 UIContext 获取绑定的 FocusController,原因就在于不同 UI 实例需要明确归属。组件被移动到新窗口或子窗口时,应重新从当前上下文获取控制器,不要长期缓存旧实例。
八、测试要制造竞争,而不是只点一遍
第一组用例连续点击左栏三条资料,在右栏刚出现时立即点笔记框,确认只有最后一条资料的输入框获焦。第二组用例在键盘弹出过程中关闭详情,确认 RELEASED 后不再出现晚到焦点。第三组用例把应用切到后台再回来,核对产品定义的恢复策略。第四组用例在手机、展开态、平板和窗口比例变化下重复相同操作。
还要测试不可获焦状态:右栏未选择资料、输入框被禁用、组件尚未出现、页面正在退出。协调器应把失败记为可解释结果,而不是无限重试。盲目重试会在目标重新出现时突然弹起键盘,用户会误以为系统自行输入。
性能方面,焦点日志不需要记录用户输入内容。任务号、序号、组件 key、状态与布尔结果已经足够。搜索词和笔记正文属于业务数据,不应为了排查键盘问题写入 HiLog。
九、路由状态不能代替焦点状态
双栏页面很容易出现一种偷懒做法:只要详情路由已经压入,就认为右栏输入框具备焦点资格。这个判断少了两个条件。第一,路由存在不代表组件已经完成布局;第二,详情可见也不代表用户明确进入编辑。把“选中资料”“详情已挂载”“笔记正在编辑”压成一个布尔值,后续每个生命周期回调都只能靠猜。
更稳妥的模型至少保留三个彼此独立的事实:selectedId 表示当前业务对象,detailReady 表示右栏目标已经进入组件树,focusOwner 表示键盘应该服务哪个区域。用户从 DOC-17 快速点到 DOC-18 时,selectedId 可以立即变化,detailReady 要等新详情实例上报,focusOwner 则先退回 NONE。只有三者重新对齐,协调器才发出 requestFocus。
这也解释了为何不能在路由回调里直接延时 300 毫秒后抢焦点。固定延时只是把竞态藏起来:性能好的设备上它显得多余,负载高时又可能不够。序号 token 与 ready 上报组合起来,等待的是确定事件,不是一个碰运气的时间窗口。
返回动作也要保持同样纪律。详情退出时先把 detailReady 置为 false,再递增序号使旧请求失效,最后结束编辑会话。顺序反过来,组件仍可能在 stopEditing() 之后上报一次 onFocus,状态机又会认为右栏重新取得资格。这个短窗口在单栏里不容易看见,在平行视界双栏同时存在时却会稳定暴露。
十、草稿、光标和焦点都要按资料隔离
焦点问题经常与草稿串写同时出现,因为二者都被错误地挂在页面级变量上。DualFocus Desk 里,笔记草稿以资料 ID 作为键;光标选择范围也跟随同一 ID。切换资料时先保存旧对象的文本与选区,再加载新对象的草稿,最后才允许新的输入框上报 ready。
不要在 onChange 里立刻把每个字符写入持久化存储。页面内可以保留轻量内存草稿,离开详情、失去焦点或达到节流窗口时再提交。这样既避免频繁 I/O,也让“切换资料”和“保存草稿”成为两个可观察事件。若保存失败,界面可以保留未提交标记,而不是通过重新请求焦点假装恢复。
光标恢复尤其要防越界。服务端合并、模板插入或草稿回滚都可能改变文本长度,恢复前需要把 selectionStart 与 selectionEnd 截断到当前长度范围。否则焦点虽然进入正确输入框,选区却指向无效位置,表现可能是光标跳到结尾,也可能是整段被意外选中。
在横竖屏或窗口比例切换时,组件实例可能重建,但业务对象没有变化。此时可恢复草稿和选区,不应复用旧 FocusController。控制器属于具体 UI 上下文,草稿属于业务状态,两者生命周期不同。把它们放进同一个全局单例,正是后续窗口迁移难以解释的根源。
十一、验收看的是行为矩阵,不是某张截图
单次“点一下能弹键盘”不能说明实现完成。验收表至少要覆盖设备形态、详情状态、用户动作和预期所有者四个维度。手机态从目录进入详情后,返回应结束 NOTE_EDITING;展开态左栏搜索时,右栏仍可见但不得抢键盘;详情输入框禁用时,请求应失败并停在 DETAIL_READY;窗口从双栏收窄为单栏时,旧右栏实例必须进入 RELEASED。
键盘导航同样重要。外接键盘通过 Tab 移动时,顺序应遵循可见组件,而不是沿用隐藏页面的旧顺序。用户主动按 Tab 进入右栏,属于新的有效意图,协调器需要更新 owner;但程序为了刷新详情而重建输入框,不应把它解释为用户再次编辑。可访问性读屏焦点与文本编辑焦点也不能简单画等号,测试记录要区分两种事件来源。
建议把验收日志压成一条稳定格式:任务号、序号、形态、目标 key、请求结果、最终 owner、键盘是否残留。FOCUS-2816 的演示结果是“焦点切换 3 次、键盘残留 0”,真正上线前还应在目标设备和真实页面上复测;文章配图只是状态机说明,不是实机性能证明。
出现失败时,先按边界定位。最终 owner 正确但键盘未收起,检查编辑控制器;owner 错误且序号落后,检查旧意图是否失效;key 不存在,检查组件条件渲染;只有窗口切换失败,检查是否缓存了旧 UIContext。这样排查比反复加延时更快,也不会把一个生命周期问题改成新的时序问题。
最后还要做一次“无焦点”验收。页面刚启动、详情尚未选择、应用进入后台和详情关闭后,都允许 owner 为 NONE。很多实现把 NONE 当异常,于是不断尝试把焦点补给某个输入框,最终形成键盘自动弹起。没有编辑意图时不分配焦点,恰恰是稳定状态。日志中应明确记录拒绝原因,例如 target_not_ready、stale_sequence 或 page_released,而不是只留下 false。对于产品经理关心的体验,可以另外观察首个有效输入意图到键盘出现的时间;对于开发排障,则保留状态迁移和组件 key。两套指标分开,既不泄露输入内容,也能判断优化究竟发生在路由、布局还是焦点请求阶段。
十二、适用边界与工程结论
这套协调器适合两个或更多输入区域同时可见、路由更新与焦点请求可能交错的页面。只有一个输入框的简单页面,直接使用组件焦点能力即可,不必引入状态机。示例的三次切换、任务号和键名都属于 Demo,不是 HarmonyOS 固定字段。
平行视界负责把页面关系呈现在大屏上,FocusController 负责在具体 UI 实例内移动焦点,TextInputController 负责结束编辑,业务状态机负责决定“此刻谁有资格编辑”。把这四层拆开之后,键盘串栏就不再是偶发视觉问题,而是一条可记录、可拒绝过时请求、可在生命周期末尾收口的工程链路。
官方参考:
更多推荐




所有评论(0)