HarmonyOS 7 ArkUI Navigation + WindowStage:折叠屏窗口宽度驱动的单双栏切换与草稿状态保持【鸿蒙心迹】
这篇不从“折叠屏有几种形态”开始,而是从一个更具体的问题切入:同一个笔记页面,在 420vp 的窄窗口里应该是单栏,在 840vp 的展开窗口里应该变成列表 + 详情双栏;真正麻烦的是,布局切换以后,用户正在编辑的草稿不能丢,当前选中的笔记也不能跳回首页。

我给 Demo 起名叫 FoldNoteDesk。这次固定一组运行状态,方便正文、日志和图片保持一致:
- Window:
420vp → 840vp - Mode:
STACK → SPLIT - Note ID:
note_20261001_07 - Draft:
DIRTY - Unsaved:
126 chars - Breakpoint:
COMPACT → EXPANDED
官方当前的多设备适配指南强调,折叠屏在展开、折叠以及窗口尺寸变化过程中,应用需要保证布局适配和状态连续;ArkUI 的 Navigation 也提供单栏、分栏和自适应模式。对我这个笔记场景来说,真正值得处理的是“窗口变化驱动布局”,而不是只判断设备是不是折叠屏。
一、我没有直接监听折叠状态,而是先监听窗口宽度
最早我也想过直接拿折叠状态做判断:
展开就双栏,折叠就单栏。
但实际项目里很快就会遇到自由窗口、分屏、横竖屏这些情况。设备即使处在展开态,应用窗口本身也可能很窄;反过来,某些宽屏设备根本不是折叠屏,也同样适合双栏。
所以最后我把布局判断依据改成了窗口宽度。
这段代码解决的问题,是在应用启动和后续窗口变化时,持续得到当前可用窗口宽度,并把结果写进全局断点状态。
import { UIAbility } from '@kit.AbilityKit';
import { display, window } from '@kit.ArkUI';
export default class EntryAbility extends UIAbility {
onWindowStageCreate(windowStage: window.WindowStage): void {
windowStage.getMainWindow().then((windowObj) => {
this.updateBreakpoint(
windowObj.getWindowProperties().windowRect.width
);
windowObj.on('windowSizeChange', (size) => {
this.updateBreakpoint(size.width);
});
});
}
private updateBreakpoint(widthPx: number): void {
const density =
display.getDefaultDisplaySync().densityPixels;
const widthVp = widthPx / density;
const breakpoint =
widthVp >= 600 ? 'EXPANDED' : 'COMPACT';
AppStorage.setOrCreate(
'windowBreakpoint',
breakpoint
);
AppStorage.setOrCreate(
'windowWidthVp',
Math.round(widthVp)
);
}
}
这里的 600vp 是当前 Demo 的业务断点,不是系统强制值。
它的作用只是表达:我的笔记应用在小于 600vp 时,列表和详情挤在一起会影响编辑;超过这个宽度以后,双栏开始有实际收益。
实际项目里,断点应该根据内容密度、字号、导航结构重新验收,而不是所有页面共用一个数字。
官方开发资料也推荐在响应式布局里根据窗口宽度划分断点,并通过 windowSizeChange 处理动态变化。这个思路比“检测设备型号”更适合长期维护。
二、布局切换不能顺手把业务状态也重建
做到这里以后,我遇到第二个问题。
最初页面里写的是:
if (this.breakpoint === 'EXPANDED') {
this.buildSplitPage()
} else {
this.buildStackPage()
}
看上去没问题,但列表、详情、编辑器实际上被当成了两套 UI 树。
一旦状态绑定做得不干净,窗口一变化,就可能出现:
- 详情页被重新创建;
- 当前 Note ID 回到默认值;
- 编辑中的 TextArea 重新取服务器旧内容;
- 草稿
DIRTY状态被重置。
所以我后面改成:布局只决定 Navigation 的显示模式,不决定当前业务数据是谁。
这段代码解决的是“同一份选中状态,在单双栏之间复用”。
@Entry
@Component
struct WorkspacePage {
private navPathStack: NavPathStack = new NavPathStack();
@StorageLink('windowBreakpoint')
breakpoint: string = 'COMPACT';
@StorageLink('selectedNoteId')
selectedNoteId: string = 'note_20261001_07';
@StorageLink('draftContent')
draftContent: string = '';
private getNavigationMode(): NavigationMode {
return this.breakpoint === 'EXPANDED'
? NavigationMode.Split
: NavigationMode.Stack;
}
build() {
Navigation(this.navPathStack) {
NoteList({
selectedNoteId: this.selectedNoteId
})
}
.mode(this.getNavigationMode())
}
}
这一层最重要的变化,是 selectedNoteId 和 draftContent 不再属于某个“单栏组件”或“双栏组件”。
它们属于 Workspace 业务本身。
页面从 420vp 切到 840vp,UI 可以重排,数据不能因为重排就换一份。
三、草稿状态我单独做了一层,不让 TextArea 成为唯一真相
接下来是最容易被忽略的一点。
如果用户正在输入 126 个未保存字符,此时设备展开,窗口触发重排,TextArea 组件自己的内部状态并不应该承担“草稿持久化”责任。
所以我给 FoldNoteDesk 加了一个很薄的 DraftStore。
当前代码解决的是:输入变化以后立刻更新业务草稿,同时标记当前笔记为脏数据。
export class DraftStore {
private static drafts: Map<string, string> =
new Map();
static save(noteId: string, content: string): void {
this.drafts.set(noteId, content);
}
static read(noteId: string): string | undefined {
return this.drafts.get(noteId);
}
static remove(noteId: string): void {
this.drafts.delete(noteId);
}
}
页面里再把编辑动作接进来:
private onDraftChanged(value: string): void {
this.draftContent = value;
this.isDirty = true;
DraftStore.save(
this.selectedNoteId,
value
);
AppStorage.setOrCreate(
'draftState',
'DIRTY'
);
}
这层 Store 在当前 Demo 里只是进程内缓存。
它能解决窗口重排、组件重建导致的草稿丢失,但不能解决应用被系统终止以后恢复的问题。
正式笔记应用需要继续把草稿落到 Preferences、数据库或者文件层。这里我没有为了文章看起来“完整”,硬把所有持久化方案全塞进一个 Demo。
四、420vp 切到 840vp 时,我只做三件事
真正跑起来以后,窗口从 420vp 变成 840vp,我不再重新初始化页面,而是只做:
- 更新
windowWidthVp - 更新
windowBreakpoint - 让 Navigation 根据状态切换模式
HiLog 里最终固定成:
Window: 420vp -> 840vp
Mode: STACK -> SPLIT
Selected: note_20261001_07
Draft restored: 126 chars

这一张 DevEco 图里可以看到几个关键点。
左侧项目不是一个只有 Index.ets 的空 Demo,而是单独拆了:
common/
BreakpointStore.ets
model/
DraftStore.ets
pages/
WorkspacePage.ets
中间代码监听 windowSizeChange,右侧展开屏已经进入双栏,底部日志则证明状态没有因为窗口变化而回到默认值。
如果日志里 Mode 对了,Selected 却变了,我会先查状态归属,不会继续调布局参数。
五、为什么我没有把所有逻辑交给 NavigationMode.Auto
Navigation 本身支持自适应模式,这个能力很实用。
但当前 Demo 仍然手动维护 COMPACT / EXPANDED,原因不是 Auto 不够好,而是我的业务还有额外状态需要跟随断点变化。
例如:
- 小窗口隐藏详情辅助工具栏;
- 大窗口固定显示最近笔记列表;
- 600vp 以上展示项目目录;
- 840vp 以上未来可能增加第三块辅助信息。
也就是说,Navigation 的 mode 只是其中一个消费者。
断点状态还会被其他组件使用。
如果一个项目只是简单的导航单栏 / 分栏,没有这些业务差异,直接使用 Auto 会更省事;如果页面同时有多块内容随窗口变化,保留一个业务断点层会更清楚。
六、窗口变化时,不要顺便重新拉一遍网络数据
这个问题我在旧项目里遇到过很多次。
有些页面把网络加载写在 aboutToAppear() 里,又因为布局切换造成页面局部重新创建,结果窗口变化一次,详情接口跟着又请求一次。
这对笔记编辑尤其危险。
服务器回来的旧正文,有可能覆盖本地还没提交的 126 个字符。
所以当前 Demo 里,草稿优先级明确高于远端正文:
private restoreNote(noteId: string): void {
const localDraft = DraftStore.read(noteId);
if (localDraft !== undefined) {
this.draftContent = localDraft;
this.isDirty = true;
return;
}
this.loadRemoteNote(noteId);
}
这段代码解决的不是“离线同步”,而是一个更小的问题:
如果本地已经有未提交草稿,就不要因为窗口重排重新拿旧数据覆盖它。
正式项目里还需要版本号、更新时间、冲突处理,这些属于同步层逻辑。
七、最终运行结果看的是“连续”,不是“双栏成功”
最后手机运行图固定在 840vp 的展开结果。

当前状态是:
- Window:840vp
- Mode:SPLIT
- Breakpoint:EXPANDED
- Draft:DIRTY
- Unsaved:126 chars
note_20261001_07 仍然是“项目会议纪要”,没有因为展开切回列表首页。
这才是我这次适配真正想验证的东西。
如果只看截图,双栏谁都能搭出来。
真正进入实际使用以后,用户会在折叠、展开、旋转、分屏之间来回切。每次变化都把输入内容清空一次,这种适配即使视觉上再漂亮,也很难算完成。
官方对折叠屏体验的核心要求里也强调状态连续。我的理解是:布局可以根据窗口变化,任务不能因为布局变化被打断。
八、这次留下的不是一个“折叠屏专用页面”
写完以后,我反而把 FoldNoteDesk 里的“折叠屏判断”删得越来越少。
最后真正保留下来的,是三层关系:
WindowStage 提供窗口变化 → Breakpoint 解释当前空间 → Navigation 和业务组件根据断点重排。
而笔记 ID、草稿、编辑状态不属于布局层。
它们应该独立存在。
这种结构的好处是,未来这个页面放到平板、自由窗口、桌面窗口里,仍然可以继续工作。
如果后续要扩展,我会继续补两块:
第一是把 DraftStore 换成真正的持久化草稿层。
第二是给断点切换加 UI 自动化验证,专门检查“窗口尺寸变化后 selectedNoteId 和 draft 长度不变”。
相比写一个“适配折叠屏”的 if/else,这两件事更像实际工程里应该继续做的工作。
参考资料
- HarmonyOS 多设备通用适配指南
https://developer.huawei.com/consumer/cn/multidevice/adaptive-apps/ - Navigation 分栏开发
https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/arkts-navigation-split-mode - 折叠屏设计原则
https://developer.huawei.com/consumer/cn/doc/doccenter-ux-design/design-principles-0000001957023989
更多推荐




所有评论(0)