HarmonyOS 7 FoldSplitContainer:悬停态三分区与折痕避让
折叠屏页面最容易在“看起来已经适配”的时刻暴露问题。展开态下,编辑区和预览区左右排列很规整;进入悬停态后,组件重新分区,输入框里的草稿却被重建,底部诊断条也可能压到折痕附近。继续转动设备时,折叠状态、窗口状态和旋转信息又不是同一时刻抵达,页面会短暂经历几组互相矛盾的组合。
这次把问题收缩到一个文章排版 Demo:HoverComposerLab。它使用 HarmonyOS 7 / API 26 新增的 FoldSplitContainer 组织主编辑区、预览区和额外诊断区,但重点不在“会用一个新组件”,而在三个工程判断:内容状态不能绑定分区组件的创建次数;折痕避让不能靠写死高度;迟到的悬停回调不能覆盖新一代窗口状态。
演示任务固定为 FSC-1118-402,报告 ID 为 hover_guard_402。页面在 11:18 记录到悬停态,11 项适配检查中完成 9 项,进度 82%;17 个表单字段全部保留,当前状态为 STABLE_HOVER。这些数字是本文演示数据契约,不冒充真实设备跑分。

一、三分区变化,不等于三份业务状态
FoldSplitContainer 提供 primary、secondary 和可选的 extra Builder,并分别接收展开态、悬停态和折叠态布局选项。API 26 的接口还包含 onHoverStatusChange,回调状态中可以观察折叠状态、是否悬停、应用旋转角度和窗口状态。这个组件解决的是不同形态下的区域组织,不会替业务决定草稿应该存在哪里、哪个异步结果仍然有效。
如果把标题、正文、封面选择和发布选项都写进 primary 子组件的局部 @State,分区被替换或条件分支重新创建时,状态就可能回到初始值。另一个常见做法是每次 onHoverStatusChange 都把当前内容拷贝一遍,看似保险,实际会产生两个真相来源:编辑组件一份,页面容器又一份,回调顺序稍有变化就互相覆盖。
HoverComposerLab 只保留一份 ComposerDraft,由页面级状态仓管理。三个 Builder 只接收可观察引用和当前布局快照,不持有独立副本。这样从展开态切到悬停态时,变化的是内容在哪个区域显示,不是内容本身。
页面约定如下:展开态中,编辑区与预览区按 2:3 比例横向分布,诊断区位于底部;悬停态中,编辑区在上、预览区在下,额外诊断区仍显示在底部;折叠态只保留编辑区和一个紧凑预览入口。比例是演示策略,并非系统固定值,实际项目应根据内容密度、最小触控区域和目标设备测试调整。
二、先固定数据契约,再让布局消费它
第一段代码解决“区域重建导致草稿丢失”的问题。DraftStore 用不可变快照保存 17 个字段,任何写入都增加修订号。Builder 不直接修改原对象,而是通过 patch() 生成新快照。这样状态变化可以被日志追踪,悬停回调也不需要复制表单数据。
interface ComposerDraft {
title: string
summary: string
body: string
coverPath: string
category: string
tags: string[]
publishOptions: Record<string, boolean>
extraFields: string[]
}
@ObservedV2
class DraftStore {
@Trace draft: ComposerDraft
@Trace revision: number = 1
constructor(initial: ComposerDraft) {
this.draft = structuredClone(initial)
}
patch(next: Partial<ComposerDraft>): void {
this.draft = { ...this.draft, ...next }
this.revision += 1
}
countPreservedFields(): number {
const fixed = 7
return fixed + this.draft.extraFields.length
}
}
@ComponentV2
struct EditorRegion {
@Param store: DraftStore
build() {
TextInput({ text: this.store.draft.title })
.onChange((value: string) => this.store.patch({ title: value }))
}
}
示例中的 17 个字段由 7 个固定字段与 10 个扩展字段组成。countPreservedFields() 只是演示验收计数,真实项目应按字段标识逐个比对,而不是把非空数量当作完整性。数组和嵌套对象也要避免原地修改,否则观察机制可能无法形成清晰的修订边界。
生命周期上,DraftStore 的存活范围应高于三个区域组件,但不必无限提升到应用全局。把它放在页面级 LocalStorage 或页面持有的可观察对象即可。离开编辑任务后,应清理临时资源与订阅;若草稿需要跨进程或重启恢复,再引入持久化,不要因为折叠适配顺手把所有输入都永久存储。
三、FoldSplitContainer 只描述分区,不承载业务分支
下面代码解决“三种形态使用不同区域比例,又不复制三套页面”的问题。三个 Builder 始终指向同一个状态仓。extra 只显示诊断信息,不承担保存按钮等关键操作;即使某种形态不显示额外区域,核心流程仍然完整。
import {
FoldSplitContainer,
ExtraRegionPosition,
PresetSplitRatio,
HoverModeStatus
} from '@kit.ArkUI'
@ComponentV2
struct HoverComposerPage {
@Local store: DraftStore = new DraftStore(makeInitialDraft())
@Local hoverSnapshot: HoverSnapshot = HoverSnapshot.initial()
@Builder primaryRegion() {
EditorRegion({ store: this.store })
}
@Builder secondaryRegion() {
PreviewRegion({ store: this.store })
}
@Builder extraRegion() {
HoverDiagnosticBar({ snapshot: this.hoverSnapshot })
}
build() {
FoldSplitContainer({
primary: () => { this.primaryRegion() },
secondary: () => { this.secondaryRegion() },
extra: () => { this.extraRegion() },
expandedLayoutOptions: {
horizontalSplitRatio: PresetSplitRatio.LAYOUT_2V3,
verticalSplitRatio: PresetSplitRatio.LAYOUT_1V1,
extraRegionPosition: ExtraRegionPosition.BOTTOM
},
hoverModeLayoutOptions: {
horizontalSplitRatio: PresetSplitRatio.LAYOUT_1V1,
showExtraRegion: true,
extraRegionPosition: ExtraRegionPosition.BOTTOM
},
foldedLayoutOptions: {
verticalSplitRatio: PresetSplitRatio.LAYOUT_1V1
},
onHoverStatusChange: (status: HoverModeStatus) => {
this.acceptHoverStatus(status)
}
})
}
}
这里使用的枚举和值来自 API 26 的当前接口声明。若项目使用不同 API 级别,应先检查 SDK 声明与设备支持范围,不能仅凭导入成功就假定目标设备具备相同行为。组件的布局参数也不应该由业务页面散落维护,最好放进一个可测试的布局策略对象。
额外区域的位置只表示分区策略,不能自动证明折痕安全。悬停态下,上下区域之间的物理折痕、系统安全区和当前窗口尺寸仍需结合真实设备观察。诊断条使用底部区域,是因为它可以被隐藏且不影响主流程;标题输入、发布按钮等关键控件不应贴近折痕或依赖额外区域才能操作。
DevEco Studio 风格配图展示同一数据契约:工程目录是 HoverComposerLab,中心代码为 FoldSplitContainer 与 acceptHoverStatus,右侧模拟器显示任务 FSC-1118-402、进度 82% 和 STABLE_HOVER,底部 HiLog 显示 generation=42、fields=17/17、lateDropped=2。它是演示图,不是实际 IDE 截图或真机证据。

四、折叠、旋转、窗口变化不是同一个事件
进入悬停态时,系统可能先报告折叠状态变化,再报告窗口尺寸或旋转变化;退出悬停态时顺序也可能不同。若每个回调都立即重算布局、保存草稿、请求预览刷新,短时间内会触发多次无效工作,旧预览结果还可能覆盖新形态页面。
HoverComposerLab 不把单个事件直接解释为最终状态。它建立一个 80 ms 的稳定窗口:每次观察到新状态只更新候选快照并增加 generation;定时器到期后,如果代次仍一致,再提交为稳定快照。80 ms 是演示值,需要通过设备日志调整,而不是通用标准。
第三段代码解决“迟到回调覆盖新形态”的问题。状态对象只提取业务真正需要的字段;generation 同时交给预览任务,结果返回时必须匹配当前代次。
interface HoverSnapshot {
foldStatus: string
isHoverMode: boolean
appRotation: number
windowStatus: string
generation: number
}
private candidate?: HoverSnapshot
private settleTimer: number = -1
private generation: number = 41
private acceptHoverStatus(status: HoverModeStatus): void {
const generation = ++this.generation
this.candidate = {
foldStatus: String(status.foldStatus),
isHoverMode: status.isHoverMode,
appRotation: status.appRotation,
windowStatus: String(status.windowStatusType),
generation
}
if (this.settleTimer >= 0) clearTimeout(this.settleTimer)
this.settleTimer = setTimeout(() => {
if (!this.candidate || this.candidate.generation !== generation) return
this.hoverSnapshot = this.candidate
this.requestPreview(generation)
this.settleTimer = -1
}, 80)
}
private applyPreview(result: PreviewResult): void {
if (result.generation !== this.hoverSnapshot.generation) {
this.lateDropped += 1
return
}
this.preview = result
}
aboutToDisappear(): void {
if (this.settleTimer >= 0) clearTimeout(this.settleTimer)
this.settleTimer = -1
}
定时器创建与释放必须成对出现。页面消失后如果仍让回调写状态,不只会造成日志噪声,还可能把旧页面数据写入复用的仓库。预览任务无法物理取消时,代次校验是最低限度的逻辑隔离;若底层支持取消,还应同时发送取消信号,节省计算与内存。
五、82% 是检查覆盖率,不是折叠动画进度
运行页把 11 项检查拆成“区域结构、状态驻留、折痕避让、事件收口”四组。当前已完成 9 项,进度是 9 / 11 = 81.8%,显示为 82%。未完成的两项是真机旋转压力测试和分屏状态复核,因此状态写成 STABLE_HOVER,而不是“全部通过”。

页面顶部状态栏固定为 11:18、Wi‑Fi、5G、信号和 81% 电量。摘要区显示任务 FSC-1118-402、报告 hover_guard_402、字段保留 17 / 17、当前代次 42。红色细圈只标记 82% 与 17/17,目的是让读者把检查覆盖与数据驻留分开理解。
03 是总览页。它回答的是“悬停态现在是否稳定、还有多少检查没做”。不能在这张页面塞进所有回调字段,否则用户只会看到一块日志墙。真正的事件顺序和迟到结果应放到诊断页。
六、诊断页记录状态组合,而不是猜系统顺序
详情页展示稳定快照:foldStatus=HALF_FOLDED、isHoverMode=true、appRotation=0、windowStatus=FULL_SCREEN、generation=42。演示中 6 次密集通知被收口为 1 次稳定提交,2 个旧代次预览被拒绝,17 个字段仍全部保留。

这些枚举文字是演示适配层输出,真实日志应同时保留原始枚举值,避免 SDK 命名变化后无法还原。详情页顶部仍是 11:18 和 81% 电量,任务 ID 与报告 ID不变;与 03 的区别很明确:03 展示结果,04 展示形成结果的事件证据。
日志建议只保留一次状态转换需要的字段:taskId、generation、foldStatus、isHoverMode、rotation、windowStatus、draftRevision、lateDropped。每个子组件都打印一遍相同对象没有价值。若要分析时序,应给同一次事件链使用同一个 generation,而不是依赖毫秒时间戳猜关联。
七、折痕避让不是把中间空出固定像素
折叠设备存在不同形态、方向和窗口状态,固定写一个 24 vp 或 40 vp 的空白无法覆盖所有情况。FoldSplitContainer 提供的分区是更高层的布局工具,但关键控件仍应遵循安全区、窗口尺寸和设计规范。标题输入、拖拽把手、确认按钮不放在区域边缘;预览内容允许裁切时,也要明确裁切策略。
在悬停态中,主编辑区需要保住最小输入高度,预览区可以降低信息密度,诊断区则可以完全隐藏。这个优先级比追求固定 1:1 更重要。实际验收至少覆盖:从展开态带草稿进入悬停、悬停时旋转、悬停转折叠、后台回前台、分屏后退出分屏、大字体与键盘弹出。
键盘是容易遗漏的一层。悬停态上半区编辑时,软键盘可能进一步压缩可用高度。页面应保证当前焦点字段可见,并避免因为键盘尺寸变化再次清空草稿。若键盘触发窗口状态变化,也应进入同一个稳定快照逻辑,而不是另开一套互相竞争的布局开关。
八、能力边界与验收结论
FoldSplitContainer 减少了三种设备形态之间的重复布局代码,但不会自动保存业务状态,也不会替应用处理异步结果。onHoverStatusChange 给出观察入口,不意味着每次回调都应该立即执行业务。布局比例、额外区域位置和动画参数仍需要在目标设备上测试。
本文演示没有声称已经通过真实设备矩阵。图片中的 82%、17/17、generation 42 和两个迟到结果,是为了让代码、日志与页面可以互相核对的固定样例。真实项目应由自动化日志与人工设备测试产生数据,再替换演示记录。
这次改造最终形成三条可复用规则:业务状态放在分区之上;设备状态经过短暂稳定窗口再提交;所有异步结果按代次验收。做到这三点后,悬停适配才从“组件换了位置”变成“内容、事件和资源都有明确边界”。
九、参考资料
- 华为开发者联盟,ArkUI API 26
FoldSplitContainer新增接口与布局选项:https://developer.huawei.com/consumer/cn/doc/doccenter-release-notes/js-apidiff-arkui-b031 - 华为开发者联盟,多设备布局基础与折叠屏窗口状态:https://developer.huawei.com/consumer/cn/doc/doccenter-ux-design/design-layout-basics-0000001795579413
- 华为开发者联盟,多设备通用适配指南:https://developer.huawei.com/consumer/cn/multidevice/adaptive-apps/
更多推荐


所有评论(0)