折叠屏适配最容易做成“把页面拉宽”。真正难的是设备从折叠到展开、再从展开回折叠时,页面结构可以变化,但用户正在编辑的内容、选中的文档和操作位置不能跟着丢。

一、这次我先做的不是双栏,而是“让草稿无论怎么折都还在”

我给这个 Demo 起名 FoldNote,是一个很普通的笔记编辑器:左边笔记列表,右边编辑区域。

在普通直板手机上,它就是典型的单页编辑器;展开到大尺寸折叠屏以后,最自然的形态是左侧列表、右侧详情。UI 本身并不难,真正麻烦的是第一次做形态切换时,我把“折叠态页面”和“展开态页面”写成了两个分支。

效果看着没问题,用户在展开态编辑一半,把设备合起来,页面切成单栏,草稿却回到了上一次保存的版本。

问题不是折叠屏,也不是 ArkUI 没有自适应能力,而是我的状态归属错了。

我当时让两个布局分支分别维护自己的 @State draftContent。窗口宽度改变后,组件树发生变化,新分支重新创建,编辑态自然跟着重新初始化。

这类问题很典型:开发者一开始关注的是“折叠后显示几栏”,而用户真正关心的是“我刚才写的字还在不在”。

所以第二版我把目标改成了三个明确指标:

  • 折叠态宽度 412 vp 时显示单栏编辑区;
  • 展开态宽度 872 vp 时显示左 360 vp、右 512 vp 的双栏工作区;
  • 当前文档始终是 layout-note-17,草稿长度 286 字,无论形态如何切换都保持不变。

到这里,布局和状态才算被拆开。

二、断点只决定“怎么排”,不应该决定“数据是什么”

HarmonyOS 的多设备适配指南一直强调按窗口尺寸和设备形态做响应式布局。对我来说,最重要的理解不是记住某一个断点数字,而是把窗口环境看成输入。

窗口从窄变宽,应该改变的是:

  • 页面是一栏还是两栏;
  • 左右区域占多少;
  • 工具栏放哪里;
  • 哪些辅助信息需要展开。

不应该跟着改变的是:

  • 当前文档 ID;
  • 用户正在编辑的正文;
  • 光标位置;
  • 未保存标记;
  • 业务请求结果。

所以我把设备布局归一成两个业务层可理解的模式:

export enum WorkspaceMode {
  COMPACT = 'COMPACT',
  EXPANDED = 'EXPANDED'
}

export class BreakpointUtil {
  static resolve(widthVp: number): WorkspaceMode {
    return widthVp < 600
      ? WorkspaceMode.COMPACT
      : WorkspaceMode.EXPANDED
  }
}

这段代码解决什么问题:把“窗口宽度”转换成稳定的布局语义,避免页面到处判断具体数字。

这里的 600 是 FoldNote Demo 自己的断点,不是系统规定的唯一值。实际项目应该根据内容密度、设计稿和目标设备验证。比如邮件、笔记和图片编辑器即使面对同一个 700 vp 窗口,也可能选择完全不同的布局。

我不太建议在十几个组件里分别写 if (width > 600)。今天只有两个断点还好,后面一旦加入平板、自由窗口、横屏,再想修改就会非常痛苦。

断点工具输出的是 COMPACT / EXPANDED,页面只消费模式。这样设备策略和页面结构之间就有了一层隔离。

三、FoldSplitContainer 解决分区,状态保留仍然要自己负责

FoldSplitContainer 是 ArkUI 为折叠屏分区场景提供的组件,可以定义 primary、secondary、extra 区域,并配置展开态、悬停态和折叠态的布局信息。它擅长的是“区域怎么摆”,不是替你保存业务草稿。

这正好符合我想要的职责分工。

在 FoldNote 里,主要区域是编辑器,次要区域是文档列表,扩展区域暂时不使用。不同设备状态下,容器可以调整区域形态,但编辑内容来自同一个状态仓库。

这段代码解决什么问题:让布局容器负责分区,而当前文档和草稿来自统一状态源。

import {
  FoldSplitContainer,
  ExpandedRegionLayoutOptions,
  HoverModeRegionLayoutOptions,
  FoldedRegionLayoutOptions,
  PresetSplitRatio
} from '@kit.ArkUI'

@Entry
@Component
struct AdaptiveWorkspace {
  @StorageLink('currentDocId') currentDocId: string = 'layout-note-17'
  @StorageLink('draftContent') draftContent: string = ''

  private expandedOptions: ExpandedRegionLayoutOptions = {
    horizontalSplitRatio: PresetSplitRatio.LAYOUT_2V3
  }

  private hoverOptions: HoverModeRegionLayoutOptions = {
    showExtraRegion: false,
    horizontalSplitRatio: PresetSplitRatio.LAYOUT_1V1
  }

  private foldedOptions: FoldedRegionLayoutOptions = {
    verticalSplitRatio: PresetSplitRatio.LAYOUT_1V1
  }

  @Builder
  primaryArea() {
    EditorPage({
      docId: this.currentDocId,
      content: this.draftContent
    })
  }

  @Builder
  secondaryArea() {
    NoteListPage({
      selectedId: this.currentDocId
    })
  }

  build() {
    FoldSplitContainer({
      primary: () => this.primaryArea(),
      secondary: () => this.secondaryArea(),
      expandedLayoutOptions: this.expandedOptions,
      hoverModeLayoutOptions: this.hoverOptions,
      foldedLayoutOptions: this.foldedOptions,
      animationOptions: { duration: 220 }
    })
  }
}

这里我故意没有把“草稿内容”写进 EditorPage 自己的私有 @State。如果编辑器组件因为布局切换重新创建,它仍然能从上层状态恢复。

真实项目里,@StorageLink 只是其中一种思路。更复杂的应用可以用状态管理 V2、应用级 Store、ViewModel 或自己的数据层。关键不是装饰器名称,而是业务状态必须高于可被替换的布局子树。

图二是 FoldNote 展开态的调试画面:DevEco Studio 左侧能看到 Breakpoint.ets、WorkspaceState.ets、AdaptiveWorkspace.ets,右侧折叠设备模拟器当前宽度 872 vp,界面已经是双栏;HiLog 里同时记录 layout-note-17 和 draftChars=286。

我做这张图时特意把“展开态双栏”和“状态保留”同时标出来,因为只截一个漂亮双栏页面,根本证明不了形态切换是否真的稳定。

四、监听窗口变化时,最怕把“布局更新”和“业务重置”写在同一个回调里

第一次实现的时候,我把所有事情都塞进窗口尺寸变化回调:

  1. 重新算宽度;
  2. 改双栏;
  3. 重新读取文档;
  4. 重建编辑器;
  5. 把滚动位置归零。

这其实是在用一个环境事件触发业务初始化。

窗口变化可能来自折叠展开、横竖屏、自由窗口变化、分屏等多种场景。它的语义只是“可用空间改变了”,并不代表用户切换了文档,更不代表草稿应该重新从数据库加载。

后来我把处理逻辑分成两类。

windowSizeChange 只做布局层信息更新;业务状态恢复只在页面真正首次创建、文档 ID 变化或者进程恢复时做。

示意代码如下。

这段代码解决什么问题:窗口尺寸变化只刷新布局,不重新初始化当前文档。

import { window } from '@kit.ArkUI'
import { hilog } from '@kit.PerformanceAnalysisKit'

const DOMAIN = 0x0000
const TAG = 'FoldNote'

export class WindowLayoutController {
  private mainWindow?: window.Window

  async bind(stage: window.WindowStage,
    onModeChange: (width: number, mode: WorkspaceMode) => void): Promise<void> {

    this.mainWindow = stage.getMainWindowSync()

    this.mainWindow.on('windowSizeChange', (size: window.Size) => {
      const widthVp = size.width
      const mode = BreakpointUtil.resolve(widthVp)

      hilog.info(
        DOMAIN,
        TAG,
        `windowSizeChange width=${widthVp}, mode=${mode}`
      )

      onModeChange(widthVp, mode)
    })
  }

  unbind(): void {
    this.mainWindow?.off('windowSizeChange')
  }
}

这段代码只把环境变化转成布局模式,至于 layout-note-17 的草稿是什么,它完全不碰。

这种分离看起来只是代码洁癖,但它直接决定折叠屏形态切换是不是稳定。因为尺寸变化属于高频环境事件,如果每次都顺便重建业务状态,迟早会碰到内容闪烁、重复请求、光标跳动和数据覆盖。

五、折叠态不是“展开态缩小版”,信息层级应该真的变化

折叠到 412 vp 后,FoldNote 不再保留左侧笔记列表。编辑器占满主要区域,用户可以继续写当前文档。

这里有一个常见误区:为了追求“一套布局”,把双栏硬压到窄屏上。结果左边列表只剩一条窄缝,右边编辑区也不够用。

响应式布局真正要做的是在不同空间里改变信息优先级。

窄屏时我把“当前编辑任务”放在第一优先级,文档列表通过返回按钮或导航进入;展开以后才把“列表 + 详情”同时展示。这种变化不会让用户觉得功能少了,因为核心任务连续。

图三就是折叠后的单栏状态。顶部状态栏时间是 10:41,当前模式明确显示 COMPACT,窗口宽度 412 vp,当前文档仍然是 layout-note-17,草稿字符仍然是 286,状态为 PRESERVED。

我很喜欢把这些诊断信息直接做进内部测试页。它比“肉眼看起来没丢”更可靠,因为测试人员可以在折叠、展开、旋转、切后台以后快速确认关键状态有没有变化。

六、展开以后恢复双栏,不能重新创建一份“看起来一样”的草稿

从 412 vp 再展开到 872 vp,页面会恢复双栏。

这里最容易出现一种隐蔽 Bug:右侧编辑器显示的内容和之前一样,看起来没有丢,但它其实是从持久化数据重新读出来的,用户刚输入但还没保存的几个字已经消失。

所以我判断“状态保留成功”时,不只对比文档 ID,还对比未保存草稿长度和 dirty 状态。

FoldNote 的测试数据里,切换前后草稿一直是 286 字。为了验证不是碰巧一致,我实际测试时还会在折叠前追加一段未保存文本,再展开检查。

另外,列表选择状态也要保持。展开以后左侧列表必须继续高亮 layout-note-17,不能默认跳回第一项。

这类“视觉上差不多”的问题,如果没有状态字段,很难在回归测试里抓到。

七、滚动位置、光标和输入法,才是第二阶段最容易漏掉的状态

草稿不丢只是第一层。

编辑器真正投入使用以后,还会出现更细的连续性问题:

  • 用户正在第 20 段编辑,展开以后滚动回顶部;
  • 光标原来在一句话中间,切换后跑到文末;
  • 选区丢失;
  • 输入法弹出时窗口变化又触发了一次布局更新;
  • 右侧编辑器宽度改变后,文本重新换行导致可视位置漂移。

这些状态不能全部简单塞到持久化数据库里。更合适的做法是分层。

文档内容属于业务数据,应该稳定保存;当前选中文档属于工作区状态;滚动位置和光标属于页面会话状态;窗口宽度则只是环境状态。

我会把会话快照单独建一个模型。

export interface EditorSessionSnapshot {
  docId: string
  cursorOffset: number
  scrollOffset: number
  dirty: boolean
}

export class WorkspaceState {
  currentDocId: string = 'layout-note-17'
  draftContent: string = ''
  snapshot: EditorSessionSnapshot = {
    docId: 'layout-note-17',
    cursorOffset: 0,
    scrollOffset: 0,
    dirty: false
  }

  saveSession(cursorOffset: number, scrollOffset: number): void {
    this.snapshot = {
      docId: this.currentDocId,
      cursorOffset,
      scrollOffset,
      dirty: true
    }
  }
}

这段代码解决什么问题:把“文档内容”和“编辑会话”分开保存,让布局重建后可以恢复工作位置。

这种模型还有一个好处:以后支持平板自由窗口、电脑窗口缩放时,不需要重新设计状态层。环境怎么变化,工作区只负责读取同一份会话快照。

八、不要把 foldStatusChange 和 windowSizeChange 当成同一件事

折叠设备上常见的两个信号,一个来自设备形态,一个来自窗口尺寸。

它们经常同时变化,但语义不同。

设备形态告诉我们“物理折叠状态发生了变化”;窗口尺寸告诉我们“应用当前真正能使用的布局空间是多少”。如果页面只是做一栏、双栏这种响应式布局,我更愿意让窗口尺寸成为主要判断依据,因为最终决定排版的是可用空间。

如果业务确实和物理形态相关,比如悬停拍摄、上下屏控制区、折痕避让,那再使用折叠状态或 FoldSplitContainer 的悬停态能力。

官方多设备适配指南也强调形态切换后要根据窗口变化及时重排并恢复状态;沉浸式场景下,折叠展开还可能触发避让区域变化,因此安全区也不应该写死。

换句话说:

“我现在是折叠屏”不等于“我现在必须双栏”。

真正的布局输入,是窗口尺寸、方向、窗口模式、避让区和当前业务任务的组合。

九、我用展开态截图做的最后一次验收

最后一次验收我从 412 vp 的折叠态开始:

当前文档 layout-note-17,草稿 286 字,输入几段内容,不点击保存;然后展开设备。

展开后窗口是 872 vp,左侧面板 360 vp,右侧编辑区域 512 vp。左侧仍然高亮同一篇笔记,右侧草稿仍然显示 286 字,会话状态没有被重置。

图四不是简单放一张“大屏版 UI”,而是把当前模式、窗口宽度、左右区域宽度、文档 ID 和草稿字数全部放在底部诊断区。红色标注分别指向双栏恢复、872 vp 和“草稿 286 字未丢失”。

如果这几个数字不在,我很难证明适配完成以后真的保持了连续性。

十、折叠屏适配真正应该验收的是“连续”,不是“能显示”

这次做 FoldNote 以后,我对多形态适配的看法更明确了。

如果验收标准只是“折叠态不崩、展开态能铺满”,很多状态问题都不会被发现。真正的验收应该围绕一个用户任务连续走完:

打开文档 → 输入内容 → 折叠 → 继续输入 → 展开 → 切换窗口大小 → 再回来。

在整条链路里确认:

当前文档没有变化,未保存草稿没有丢,列表选中项没有重置,滚动和光标尽量恢复,布局没有覆盖系统避让区,窗口改变没有触发多余的业务初始化。

这才是我理解的折叠屏适配。

FoldSplitContainer 帮我们处理分区,响应式断点帮我们选择布局,窗口事件告诉我们环境变化。但用户状态要不要丢,最终仍然取决于应用自己的架构。

一个页面从单栏变成双栏其实很快,真正需要时间的是把“布局变化”和“业务状态”彻底拆开。

做到这一步以后,后续适配平板、自由窗口甚至鸿蒙电脑时,很多代码都能继续用,因为我们已经不是在写“某款折叠屏专用页面”,而是在写一个能够接受不同窗口环境的工作区。

十一、自由窗口加入以后,按设备型号判断很快会失效

FoldNote 做到后面,我特意把它放进多窗口环境里测试。这个动作让我更加确定:不要把布局逻辑写成“折叠屏就双栏,普通手机就单栏”。

同一台展开态折叠设备上的应用窗口,也可能被用户拖成很窄的自由窗口;反过来,大尺寸设备即使不是折叠形态,也可能有足够空间显示双栏。如果布局绑设备类型,窗口尺寸一变化,就会出现设备明明展开但可用空间已经不够,页面仍然强行双栏的情况。

所以 FoldNote 的最终规则一直围绕“当前可用窗口”建立。窗口宽时列表和编辑区同时存在,窗口窄时列表退回导航层。设备物理形态只在需要处理悬停、折痕或特殊交互时参与判断。

测试范围也因此从“折叠 / 展开”扩大成折叠态全屏、展开态全屏、展开态自由窗口、横屏、分屏、系统栏显隐和输入法弹出。这些场景最终都可能改变窗口尺寸或避让区域。

HarmonyOS 的沉浸式布局文档提到,窗口形态变化、旋转以及多折叠设备的折叠展开都可能引起避让区域更新。所以业务状态保持不动,布局状态允许持续变化,避让信息作为环境数据实时更新,才更适合多形态应用。

十二、形态切换动画要克制,连续性比“炫”更重要

第二版 FoldNote 我给单双栏切换加过明显位移动画。展开时左侧列表从边缘滑进来,右侧编辑器缩放到目标宽度,看起来很有设计感。真机多试几次以后我把动画收敛了。

编辑场景里用户注意力本来就在文字上,设备展开只是环境变化,不是一次业务跳转。如果整个页面大幅移动,用户会重新寻找刚才的编辑位置,长文档里尤其明显。

现在我的原则是:布局变化可以有短过渡,但不要把内容当成新页面重新入场;当前光标附近文本尽量保持视觉位置;列表出现时不要抢焦点;输入法存在时优先保证当前编辑区域可见。

这也是为什么截图里我更强调 PRESERVED、当前文档和草稿字数,而不是做夸张的折叠动画。对于生产力工具来说,状态连续比动画更能说明适配质量。

十三、我最后用一张状态验收表代替“肉眼看看”

第一组验收是布局连续性:412 vp 单栏是否正确,872 vp 双栏是否正确,412 → 872 → 412 往返以后能否恢复,连续快速调整窗口宽度时有没有重叠和闪烁。

第二组是数据连续性:当前文档 ID 是否一直为 layout-note-17,未保存草稿 286 字是否保持,dirty 标记是否一致,左侧选中项是否仍然对应当前文档。

第三组是会话连续性:光标位置是否可恢复,滚动位置是否合理,输入法打开时切换形态后编辑区是否仍然可见,焦点有没有被列表抢走。

第四组是环境连续性:系统栏变化后内容是否重新避让,自由窗口缩放时断点是否按实际宽度工作,从后台回来以后是否只恢复一次业务状态,而不是重复加载。

把这些项目列出来以后,折叠屏适配就从“看起来差不多”变成了可以测试、可以回归的工程问题。真正昂贵的 Bug 通常不是某个按钮偏了几个 vp,而是用户正在做的事情被打断。

十四、这套状态分层并不只适用于笔记应用

换成邮件应用,状态可能是当前邮件、草稿正文和附件上传进度;换成电商应用,状态可能是当前商品、规格选择和临时购物车;换成代码编辑器,则可能是当前文件、光标、未保存缓冲区和搜索结果。

这些状态都不应该因为设备展开一下就重新初始化。

所以我现在做多形态页面时,会在写 UI 之前先画两张图:一张布局树,一张状态树。布局树可以因为 COMPACT / EXPANDED 重组,状态树尽量保持稳定。只要这两棵树没有混成一棵,折叠屏、平板和自由窗口的很多问题都会简单很多。

FoldSplitContainer 是布局工具,不是状态管理器;断点是环境判断,不是业务判断;形态切换是视图变化,不应该被当成用户重新进入应用。这三句话基本就是 FoldNote 这次适配最后留下的工程结论。

参考资料

Logo

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

更多推荐