地址表单在直板机上看着很普通:联系人、手机号、详细地址,再放一个“保存地址”按钮。换到折叠设备,麻烦往往不在布局第一次展开,而在用户正输入手机号时把设备从展开态折到悬停态。窗口高度、系统避让区、页面可视高度和焦点树会在很短时间里连续变化。每来一次回调就立即重排,页面很容易抖两下,按钮被键盘盖住,光标也可能跑回第一个输入框。

本文用 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 内没有稳定快照时,页面不能永久禁用提交按钮,可以退化为可滚动单栏并给出明确状态;屏幕朗读也不应把每次候选高度都播报出来,只在最终布局采用或操作不可继续时提示。否则视觉抖动虽然消失了,辅助功能用户却会听到一串没有意义的状态变化。稳定门禁的对象是布局决策,不是把用户锁在等待态。

参考资料:

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐