HarmonyOS 7 AvoidArea:折叠态表单键盘遮挡与焦点回填
地址表单在直板机上看着很普通:联系人、手机号、详细地址,再放一个“保存地址”按钮。换到折叠设备,麻烦往往不在布局第一次展开,而在用户正输入手机号时把设备从展开态折到悬停态。窗口高度、系统避让区、页面可视高度和焦点树会在很短时间里连续变化。每来一次回调就立即重排,页面很容易抖两下,按钮被键盘盖住,光标也可能跑回第一个输入框。
本文用 FoldFormLab 处理这个细节。页面叫 AddressEditPage,协调器是 SafeAreaCoordinator。演示任务 FOLD-1818-307 在 18:18 发生形态切换,布局代次为 307,当前字段是 receiverPhone。页面收到的应用侧形态标签为 HALF_FOLDED,根节点可视高度从 1412px 收缩,键盘占用估算为 684px,安全内容高度稳定在 728px。合流阶段进度显示 68%,状态是 WAITING_STABLE_AREA,120ms 后才进入 STABLE_READY。

一、不要让三个回调分别决定布局
折叠设备是否可折叠,可以用 display.isFoldable() 判断;当前折叠状态可以通过 display.getFoldStatus() 获取,状态变化则由 display.on('foldStatusChange') 监听。窗口侧的 getWindowAvoidArea() 和 on('avoidAreaChange') 负责系统栏、挖孔区、导航指示区等避让信息。它们描述的不是同一件事。
形态变化回答“设备正在怎么折”,避让区回答“哪些区域不能放关键内容”,页面根节点的 onAreaChange 回答“组件现在实际拿到了多大空间”。软键盘出现时,系统默认策略通常能优先保证输入框可见,却不保证输入框下面的“保存”按钮也一定露出来。这一点放到短屏或悬停态尤其明显。
所以 SafeAreaCoordinator 不把任何单个回调当最终答案。它收集折叠状态、窗口系统避让、根节点高度和当前焦点,生成同一代次的候选快照。候选值连续 120ms 不再变化,才发布给页面。120ms 是 Demo 的交互预算,不是系统常量,真实项目要结合设备与动画节奏测量。
这里还有一个容易混淆的边界:HALF_FOLDED 是 Demo 为日志和页面定义的业务标签,不冒充 display.FoldStatus 的官方枚举名称。接收到的系统值先按当前 SDK 声明解释,再映射成业务语义,避免把不同设备形态的数值硬编码到页面。
二、监听要成对,第一次快照不能等事件碰巧到来
第一段代码解决两个问题:页面进入时主动读取初值;页面离开时按同一个回调引用注销监听。只注册不注销,会让旧页面继续接收形态变化;只等回调而不读初值,则可能在设备保持不动时一直拿不到首屏数据。
import { display, window } from '@kit.ArkUI';
import { Callback } from '@kit.BasicServicesKit';
export class SafeAreaCoordinator {
private mainWindow?: window.Window;
private layoutRev: number = 306;
private foldStatus: display.FoldStatus = display.getFoldStatus();
private foldCallback: Callback<display.FoldStatus> = (value) => {
this.foldStatus = value;
this.enqueueCandidate('FOLD_CHANGED');
};
async attach(stage: window.WindowStage): Promise<void> {
this.mainWindow = stage.getMainWindowSync();
this.foldStatus = display.getFoldStatus();
display.on('foldStatusChange', this.foldCallback);
this.mainWindow.on('avoidAreaChange', (info) => {
if (info.type === window.AvoidAreaType.TYPE_SYSTEM ||
info.type === window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR ||
info.type === window.AvoidAreaType.TYPE_CUTOUT) {
this.enqueueCandidate('AVOID_AREA_CHANGED');
}
});
this.enqueueCandidate('INITIAL_SNAPSHOT');
}
detach(): void {
display.off('foldStatusChange', this.foldCallback);
this.mainWindow?.off('avoidAreaChange');
this.mainWindow = undefined;
this.cancelPendingCommit();
}
}
mainWindow.off('avoidAreaChange') 在这里清理该窗口上的对应监听;如果同一窗口还承载别的模块,工程里应保存并传回具体回调,避免一把清掉其他订阅。detach() 还要取消定时提交,因为页面退出后再发布 STABLE_READY,会把已经失效的布局状态写回新页面。
首个快照主动读取 getFoldStatus(),系统栏和导航区则通过 getWindowAvoidArea() 补齐。示例没有用折叠状态直接推断窗口尺寸,因为分屏、自由窗口和跨设备流转也会改变窗口空间。同一个折叠状态下,可用宽高并不唯一。
三、把可视高度当事实,把键盘高度当推导值
第二段代码解决“键盘高度没有被某个单独回调可靠交付”的问题。页面根容器通过 onAreaChange 报告实际高度,协调器拿最近一次无键盘基线减去当前高度,得到本轮的遮挡估算。估算只用于布局,不写成系统事实。
type AreaCandidate = {
rev: number;
rootHeight: number;
baseHeight: number;
bottomSystemAvoid: number;
focusedId: string;
};
private pending?: AreaCandidate;
private commitTimer: number = -1;
acceptRootArea(height: number, focusedId: string): void {
const rev = ++this.layoutRev; // 本轮为 307
this.pending = {
rev,
rootHeight: height,
baseHeight: this.lastExpandedRootHeight,
bottomSystemAvoid: this.readBottomAvoid(),
focusedId
};
this.state = 'WAITING_STABLE_AREA';
this.progress = 68;
if (this.commitTimer >= 0) clearTimeout(this.commitTimer);
this.commitTimer = setTimeout(() => this.commitStable(rev), 120);
}
private commitStable(boundRev: number): void {
const c = this.pending;
if (!c || c.rev !== boundRev) return;
const reduced = Math.max(0, c.baseHeight - c.rootHeight);
const keyboardPx = Math.max(0, reduced - c.bottomSystemAvoid);
const safeHeight = Math.max(480, c.rootHeight - c.bottomSystemAvoid);
this.publish({ rev: c.rev, keyboardPx, safeHeight,
focusedId: c.focusedId, state: 'STABLE_READY' });
}
这段计算故意保守。根节点变矮不一定全由键盘造成,折叠、窗口拖拽和系统栏变化也会参与,所以日志里写“keyboardPxEstimate”,而不是“系统键盘高度”。当候选代次在 120ms 内又增加,旧定时器即使被调度,也会被 rev 栅栏挡掉。
safeHeight 设置 480px 下限,是为了避免一次瞬态零尺寸让布局坍缩。这个值同样是 Demo 的页面约束,实际产品应从按钮、当前输入框、标题和错误提示所需的最小高度反推。空间低于最小值时,与其继续压缩,不如让表单内部滚动。
工程目录分为 pages/AddressEditPage.ets、service/SafeAreaCoordinator.ets、model/LayoutSnapshot.ets 和 components/StickySubmitBar.ets。下面的 DevEco Studio 风格图按照本文数据生成,右侧模拟器停在 68% 合流阶段;它是演示说明图,不是真实 IDE 或真机测试证据。

四、布局稳定之后,再把焦点交回同一个字段
折叠过程中直接调用 requestFocus() 往往太早。目标节点可能正在从双栏树移动到单栏树,当前帧还不存在或不可见。正确顺序是:保存焦点键值;发布稳定布局;确认新组件树已经挂载;再通过当前页面的 UIContext 请求焦点。
第三段代码解决焦点回填和提交按钮可见性。receiverPhone 的 key 不随布局模式改变,双栏与单栏渲染都复用同一个业务键值。
@Entry
@Component
struct AddressEditPage {
@State snapshot: LayoutSnapshot = LayoutSnapshot.initial();
@State phone: string = '';
private scroller: Scroller = new Scroller();
private restoreFocus(): void {
if (this.snapshot.focusedId.length === 0) return;
this.getUIContext().getFocusController()
.requestFocus(this.snapshot.focusedId);
}
build() {
Column() {
Scroll(this.scroller) {
Column({ space: 16 }) {
TextInput({ text: this.phone, placeholder: '收件人手机号' })
.key('receiverPhone')
.onFocus(() => this.coordinator.markFocus('receiverPhone'))
// 其他地址字段
}
}.layoutWeight(1)
StickySubmitBar({ enabled: this.snapshot.state === 'STABLE_READY' })
}
.height(this.snapshot.safeHeight)
.onAreaChange((_oldArea, newArea) => {
this.coordinator.acceptRootArea(
Number(newArea.height), this.snapshot.focusedId);
})
.onDidBuild(() => this.restoreFocus())
}
}
requestFocus() 依赖目标组件已经挂树且可见。示例把请求放在新快照触发的构建完成后,但仍要检查返回结果;返回 false 或抛出错误时只记录诊断,不循环抢焦点。用户已经主动点击别的输入框时,旧代次也不能把焦点抢回去,因此正式实现要把 focusedId 与 layoutRev 一起比较。
03 图展示合流尚未完成的运行态:任务 FOLD-1818-307、代次 307、字段 receiverPhone、估算键盘 684px、进度 68%、状态 WAITING_STABLE_AREA。红色标注提醒此时按钮还不能根据瞬态高度固定位置。

五、诊断页要把“收到事件”和“采用结果”分开
如果日志只写“折叠状态变化”,很难判断按钮为什么仍被挡住。Demo 的事件账本分别记录 FOLD_CHANGED、ROOT_AREA_CHANGED、AVOID_AREA_CHANGED、FOCUS_RETAINED、STABILITY_TIMER 和 SNAPSHOT_COMMITTED。前五个是输入,最后一个才是页面采用的结果。
18:18:26 收到 HALF_FOLDED 业务标签;同秒根节点变高信息与底部系统避让到达,焦点仍是 receiverPhone;布局代次增加到 307,稳定计时器重新开始。18:18:27 连续 120ms 没有新候选,发布 safeHeight=728px、keyboardPxEstimate=684px,状态进入 STABLE_READY。
04 图专门展示这条诊断链,与 03 的运行页不同。红圈落在 SNAPSHOT_COMMITTED,箭头说明“120ms 稳定后采用”。这些时间、尺寸和状态是演示合同,不声称来自某一台真实设备的测量结果。

六、异常路径比正常展开更值得测
第一组测试从展开态进入悬停态,键盘保持打开,验证输入文本、选择区和焦点键值不丢,提交栏最终可见。第二组在 120ms 内连续注入三次根高度,只有最后代次 307 可以发布,前两次定时任务必须被拒绝。
第三组让页面在 WAITING_STABLE_AREA 阶段退出,检查折叠与避让监听是否注销,计时器是否取消。第四组模拟 requestFocus() 失败,页面应保留输入值并显示可操作布局,不能因为焦点失败阻止保存。第五组在分屏模式下重复相同折叠状态,证明布局依赖实际窗口空间,而不是依赖“展开/折叠”字符串。
还要覆盖字体放大、错误提示变成两行、地址输入框使用输入法候选栏、导航条显示状态变化等情况。安全高度不是固定屏幕高度减固定键盘高度;任何硬编码都可能在另一种字体、导航模式或窗口形态下失效。
对于自定义键盘,supportAvoidance: true 能帮助输入框避让,但官方也提醒:默认能力不一定保证输入框下面的操作区可见。页面仍需根据实际可视空间决定滚动范围与提交栏位置。对系统键盘则不要套用自定义键盘的组件高度回调,应该从真实窗口和页面变化建立自己的证据链。
七、稳定区门禁解决的是连续性,不是某一个像素值
折叠态表单的关键,不是找出一个“万能键盘高度”。真正需要守住的是三件事:输入内容不丢;用户正在编辑的字段不被无故替换;保存按钮在布局稳定后可见且可操作。
FOLD-1818-307 在 68% 时仍处于候选合流,所以页面没有提前宣称适配完成。等代次 307 的可视高度、避让区和焦点连续稳定 120ms,才提交 728px 安全内容区并回填 receiverPhone 焦点。多等这一小段时间,换来的是少一次抖动、少一次误触,也少一个很难复现的“折一下按钮就没了”。
实际落地时,还应把稳定等待上限和无障碍体验一起纳入验收。120ms 内没有稳定快照时,页面不能永久禁用提交按钮,可以退化为可滚动单栏并给出明确状态;屏幕朗读也不应把每次候选高度都播报出来,只在最终布局采用或操作不可继续时提示。否则视觉抖动虽然消失了,辅助功能用户却会听到一串没有意义的状态变化。稳定门禁的对象是布局决策,不是把用户锁在等待态。
参考资料:
更多推荐


所有评论(0)