一、窗口缩窄后,真正怕丢的是用户输入

把笔记页压进一个很窄的任务窗口,最直观的变化是标题换行、按钮被挤到下一行,或者编辑框高度突然减半。开发者容易把这类问题归结为“宽度小了”,于是按窗口尺寸做一次全局判断。然而主窗口宽,不代表放在侧边栏或浮动卡片内的编辑器也宽。对可复用组件来说,父容器给了多少空间,比整块屏幕有多宽更值得关注。

我用 PocketDraft 设计了一组窄幅编辑状态。任务 PD-126 的数据契约是:容器宽度 328vp,业务断点 360vp,断点结果 XS,布局模式 COMPACT,编辑框高度 112vp,当前草稿记录 48个汉字,还没有点击保存,状态 UNSAVED。右侧演示图会展示一个 9:16 手机页面,但这只是方便查看窄窗布局的示意画布,并不是已经调用系统闪控窗能力的证据。

更重要的是:重排绝不能顺手重置草稿。例如把“紧凑”和“完整”编辑器分别放到两个 if/else 分支,并给它们各自管理输入内容。条件反复变化时,组件可能重新创建,输入法焦点和编辑状态也容易被带走。与其在两套界面之间搬文本,不如让文本状态始终有唯一所有者,只改变它的呈现尺寸。

二、ContainerReader 解决的是局部尺寸,不是系统键盘高度

华为 2026-09-09 发布的《容器断点(ContainerReader)》说明:这是从 API 26.0.0 起提供的组件级响应式布局能力,可以通过双向绑定读取容器尺寸和宽度断点。与按窗口宽度判断不同,ContainerReader 读到的是该组件由父布局分配的空间。文档特别强调,size、widthBreakpoint 两个状态绑定时都要使用 !!;不带 !! 不能获得预期的双向更新。

本例把 360vp 作为自定义宽度阈值,低于该值使用紧凑呈现。这个 360 只是 PocketDraft 业务选定的断点,不代表 HarmonyOS 闪控窗统一的系统规格;在另外的设备或字体放大设置下,应重新审视值是否合适。对于输入框被软键盘遮盖的问题,仍需使用系统的软键盘避让机制,ContainerReader 本身不能替代键盘避让,更不能从宽度推断键盘是否正在显示。

与传统的 onAreaChange 方案相比,ContainerReader 的好处不是减少了几行代码,而是把“谁决定组件尺寸”和“谁消费测量结果”的职责划清。组件不能通过修改双向绑定回来的 size.width 反向强行设置自己的宽度,否则会制造难追踪的布局反馈。父容器控制可用空间,内部编辑页只负责选择布局。

三、第一段代码:先给草稿一个稳定所有者

为了讨论状态保持,先让输入内容属于页面级 @State,不要属于某个切换分支。示例初始化了一段固定汉字文本,作为模型输入;真实应用应按新建/继续编辑场景从用户数据恢复,而不是把业务内容写死在源文件里。显示的“48字”只统计汉字,标点不计入统计口径,避免把字数和字符串 length 混为一谈。

// entry/src/main/ets/pages/DraftPage.ets(片段一)
import { ContainerReader, Size } from '@kit.ArkUI';
import { preferences } from '@kit.ArkData';

@Entry
@Component
struct DraftPage {
  private taskId: string = 'PD-126';
  @State size: Size = { width: 0, height: 0 };
  @State bp: WidthBreakpoint = WidthBreakpoint.WIDTH_MD;
  @State draft: string = '核对门店清单,周五前补齐物料,确认库存与活动方案。同步给区域负责人,并整理下周的重点事项,避免任何重要遗漏。';
  @State draftChars: number = 48;
  @State saveState: string = 'UNSAVED';
}

这只是组件的数据声明片段,完整工程需要把下面的 build 方法与保存逻辑放入同一结构体。源字符串包含 48 个汉字和若干标点。若业务需要字节长度、用户感知字符数或 emoji 字素统计,应另外定义规则,不能把这个演示统计当成适用于所有输入法的字符计数器。

初始化时 size.width 为 0,不能直接认为用户窗口真的只有 0vp。这是等待布局测量的初始状态。官方建议父容器应有明确的大小约束,避免依靠子节点先决定父节点尺寸。工程上我更愿意在首帧尚未测量时显示保守的单列结构,而不是先按宽屏绘制一套,随后突然跳成紧凑模式。

四、第二段代码:只调整编辑框高度,不改数据容器

接下来解决重排问题。使用一个 ContainerReader 包住编辑内容,宽度断点由 .breakpointConfig({ width: [360] }) 决定。最关键的是:TextArea 只有一个实例;无论宽窄,都绑定同一个 this.draft。分支判断只作用于高度和说明文字,避免通过两棵子树间切换完成布局。

// DraftPage.ets(片段二,放在同一个组件的 build 方法中)
build() {
  Column({ space: 12 }) {
    Text('PocketDraft 窄窗笔记')
      .fontSize(20).fontWeight(FontWeight.Bold)
    ContainerReader({
      size: this.size!!,
      widthBreakpoint: this.bp!!
    }) {
      Column({ space: 10 }) {
        Text(`任务 ${this.taskId} · ${this.bp === WidthBreakpoint.WIDTH_XS ? 'COMPACT' : 'NORMAL'}`)
        TextArea({ text: this.draft, placeholder: '写下待办事项' })
          .width('100%')
          .height(this.bp === WidthBreakpoint.WIDTH_XS ? 112 : 184)
          .onChange((value: string) => this.onDraftChange(value))
        Button('保存草稿').onClick(() => { this.saveDraft(); })
      }.width('100%')
    }
    .breakpointConfig({ width: [360] })
    .width('100%')
    .height(330)
  }
  .width('100%')
  .padding(16)
}

图中的 328vp 是给容器分配的演示宽度,不是由 size 状态设置出来的。宽度从 328vp 变化到超过断点时,编辑框高度由 112vp 切换到 184vp,草稿绑定对象没有变化。这个方案避免了因为条件替换编辑组件而造成的局部重建,但不自动保证光标选择区和输入法合成态完全不变;这些仍需通过真实设备验收。

另一个边界是高度。父容器在示例里给了 330vp 的明确高度,只用于解释尺寸来源;复杂页面中的键盘、系统避让区域、导航栏和小窗标题栏会挤占内容空间。不能拿这个固定值当所有设备都适用的写法。若需要自适应剩余高度,应把父容器、滚动区和底部按钮的布局关系一起设计。

五、第三段代码:用户编辑不等于已经保存

一段草稿存在 @State 中,只能说明同一页面实例内的重排没有主动清空文本,不能说明应用进程被结束后还会恢复。为此需要把保存行为与布局变化解耦:文字变化时标为 UNSAVED,点击“保存草稿”才执行持久化;保存过程中再次输入,还要避免旧请求结束后把新草稿误标为已保存。

// DraftPage.ets(片段三,补充相同组件的方法)
private onDraftChange(value: string): void {
  this.draft = value;
  this.draftChars = (value.match(/[\u4e00-\u9fff]/g) ?? []).length;
  this.saveState = 'UNSAVED';
}

private async saveDraft(): Promise<void> {
  const snapshot = this.draft;
  try {
    const store = await preferences.getPreferences(getContext(this),
      { name: 'pocketdraft' });
    await store.put('PD-126', snapshot);
    await store.flush();
    this.saveState = this.draft === snapshot ? 'SAVED' : 'UNSAVED';
  } catch (_error) {
    this.saveState = 'FAILED';
  }
}

这里的 Preferences 只承担显式保存,与上轮隐私同意管理的业务语义不同;代码使用的是官方持久化 API,而非凭空假设存在“保存草稿”系统服务。flush() 返回前都不应把界面显示为 SAVED。如果用户在等待期间继续输入,snapshot 与当前文本不同,状态重新保持 UNSAVED。对更高要求的生产项目,还应处理重入保存、页面退出时的并发操作和敏感内容加密策略。

这张 DevEco 白色主题图是布局开发场景的绘制示意,左侧是 PocketDraft 目录,中间展示容器断点与 TextArea,右侧手机预览固定为 PD-126、328vp、COMPACT、112vp,底部 HiLog 也是预设的演示日志。它不是一次真实编译的结果,也不能证明实际焦点行为已通过回归测试。示意记录中的 10:41:02 width=328vp bp=XS 和 10:41:04 draftChars=48 只用于说明预期状态。

六、如何设计边界验收,而不是只看一张好看的图

我会先检查容器尺寸在断点两侧反复变化的过程:320→328→359→360→420vp,草稿内容是否仍由同一状态持有;接着验证切换前后 TextArea 的焦点、光标、选择范围和输入法组合输入;最后模拟系统键盘弹出时底部按钮是否能被看到。这里列的是待执行的验收路径,不是已在真机跑通的测量数据。

手机示意页的状态为 PD-126、328vp、阈值360vp、COMPACT、编辑区112vp、48汉字、UNSAVED、键盘隐藏,表示尚未点击“保存草稿”。如果你看到输入内容还在,但保存状态仍为 UNSAVED,这恰好说明两个概念没有混在一起:布局重排中的即时保留和进程重启后的持久恢复是不同的验收项。

还需要对 onChange 的触发频率有所克制。频繁输入时不应每个字符都立刻落盘,避免给磁盘写入和界面状态造成无意义的压力;也不要靠“每次容器变宽就保存”来补救状态组织缺陷。真正保存可使用按钮或经过审慎设计的防抖,失败时明确给出重试入口。页面离开时自动保存属于额外的产品决策,不能无条件保证进程被系统回收前一定完成异步写入。

七、取舍:断点服务于内容,而不是让内容服从断点

本例的重点不是把一个数字从 328 改成 360,而是把容器测量、输入编辑和持久化分成三条独立链路。ContainerReader 提供局部尺寸和断点,TextArea 负责输入,Preferences 只在业务明确要求时落盘。缩窗只重排,不清稿;保存失败不冒充成功;用户继续编辑时,上一轮保存不能覆盖新状态。

对闪控窗、侧边栏、可复用编辑卡片一类场景,这是一条更稳的工程思路。但它不是系统闪控窗创建或能力适配教程,也未验证具体设备的软键盘策略。产品上线前仍须按实际 SDK、支持设备和页面形态补齐焦点、无障碍与生命周期测试。设计演示到这里成立,实机结论仍需真正的设备日志。

官方资料(已核对页面):

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐