HarmonyOS 7 / API 26 折叠屏适配实战:窗口断点、双栏切换和状态保留一次验清
折叠屏适配最容易出问题的地方,不是页面能不能铺满屏幕,而是窗口变化以后状态还能不能保住。用户从外屏切到内屏,或者在平板、鸿蒙电脑窗口里拖动宽度,页面结构可能从单栏变成双栏。如果列表选中项、滚动位置、详情页参数都丢了,体验会很差。
这篇按 HarmonyOS 7 / API 26 的多设备适配思路来拆:先把窗口宽度转成断点,再把单双栏结构和业务状态分开,最后用两条可复现路径验证。
先定义断点,不要到处写魔法数字
我一般会先把窗口宽度分成三档:compact、medium、expanded。
| 断点 | 典型场景 | 页面结构 |
| compact | 手机竖屏、折叠屏外屏 | 单栏列表,详情新页打开 |
| medium | 折叠屏半展开、小平板 | 列表加轻量详情预览 |
| expanded | 平板横屏、鸿蒙电脑窗口 | 左列表右详情双栏 |
断点判断单独封装,后面页面只消费结果。
~~~ts
type WindowBreakpoint = 'compact' | 'medium' | 'expanded'
function getBreakpoint(widthVp: number): WindowBreakpoint {
if (widthVp < 600) {
return 'compact'
}
if (widthVp < 840) {
return 'medium'
}
return 'expanded'
}
~~~
这样做的好处是清楚。后面产品说 840 这个点要调整,也只改一个地方。
页面状态不要绑死在布局里
问题写法通常是:单栏页面有一套状态,双栏页面又有一套状态。窗口一变,组件重建,状态跟着丢。
~~~ts
type RecipeListState = {
selectedId?: string
scrollIndex: number
keyword: string
}
class RecipeStateStore {
private state: RecipeListState = {
selectedId: undefined,
scrollIndex: 0,
keyword: ''
}
snapshot(): RecipeListState {
return { ...this.state }
}
update(next: Partial<RecipeListState>): void {
this.state = { ...this.state, ...next }
}
}
~~~
这个 store 不关心当前是单栏还是双栏,它只保存“用户在看什么”。布局切换时,页面从这里恢复。
案例一:外屏单栏切到内屏双栏
单栏时用户点列表进入详情;展开后应该变成左列表右详情,而且仍然选中刚才那条。
~~~ts
class AdaptiveRecipePage {
private store = new RecipeStateStore()
private breakpoint: WindowBreakpoint = 'compact'
onWindowSizeChange(widthVp: number): void {
const next = getBreakpoint(widthVp)
if (next === this.breakpoint) {
return
}
const snapshot = this.store.snapshot()
this.breakpoint = next
this.renderByBreakpoint(snapshot)
}
selectRecipe(id: string): void {
this.store.update({ selectedId: id })
const snapshot = this.store.snapshot()
this.renderByBreakpoint(snapshot)
}
private renderByBreakpoint(state: RecipeListState): void {
if (this.breakpoint === 'compact') {
renderSingleColumn(state.selectedId)
return
}
renderTwoColumn(state.selectedId)
}
}
~~~
这里的关键是:窗口变化只改变布局,不重置 selectedId。状态和布局分开以后,单双栏切换就不会把用户当前选择冲掉。
案例二:拖动窗口宽度后滚动位置丢失
平板或鸿蒙电脑窗口拖动宽度时,列表可能重排。如果滚动位置没有单独保存,页面会跳回顶部。
~~~ts
class ListScrollKeeper {
private lastIndex = 0
onScroll(index: number): void {
this.lastIndex = index
}
restore(): number {
return this.lastIndex
}
}
class AdaptiveListController {
private scrollKeeper = new ListScrollKeeper()
onVisibleAreaChange(firstVisibleIndex: number): void {
this.scrollKeeper.onScroll(firstVisibleIndex)
}
onLayoutRebuild(): void {
const index = this.scrollKeeper.restore()
scrollToIndex(index)
}
}
~~~
滚动位置不一定要保存像素值。很多列表在数据稳定时,保存 firstVisibleIndex 更实用,适配重排后也更容易恢复。
断点切换要有日志
适配问题如果没有日志,很难知道到底是窗口事件没触发,还是触发后状态没恢复。
~~~ts
type BreakpointTrace = {
oldValue: WindowBreakpoint
newValue: WindowBreakpoint
widthVp: number
selectedId?: string
scrollIndex: number
}
function printBreakpointTrace(trace: BreakpointTrace): void {
console.info(
'[breakpoint]',
trace.oldValue + '->' + trace.newValue,
'width=' + trace.widthVp,
'selected=' + (trace.selectedId ?? 'none'),
'scroll=' + trace.scrollIndex
)
}
~~~
每次窗口变化都输出一次,回归时能直接看出状态是否被保住。
本地验证脚本
先不接真实 UI,也可以验证状态逻辑。
~~~ts
function verifyFoldableState(): void {
const store = new RecipeStateStore()
store.update({ selectedId: 'recipe-12', scrollIndex: 40, keyword: 'noodle' })
const compact = getBreakpoint(390)
const expanded = getBreakpoint(900)
const snapshot = store.snapshot()
console.info('[verify-breakpoint]', compact, expanded)
console.info('[verify-state]', snapshot.selectedId, snapshot.scrollIndex, snapshot.keyword)
}
~~~
预期结果是断点从 compact 到 expanded,但 selectedId、scrollIndex、keyword 都不丢。
方案对比
| 方案 | 好处 | 问题 |
| 布局里直接放状态 | 写起来快 | 窗口一变容易丢状态 |
| 全局 store 保存所有状态 | 恢复方便 | 容易过度设计 |
| 页面级状态仓库 | 状态清楚,适配成本低 | 需要维护快照和恢复入口 |
我更推荐页面级状态仓库。它比把状态散在组件里稳,也比把所有东西塞进全局 store 更轻。
回归检查
| 场景 | 操作 | 通过标准 |
| 外屏到内屏 | 选中一条后展开 | 双栏仍显示同一条详情 |
| 拖动窗口 | 列表滚动到中间再改宽度 | 滚动位置不回到顶部 |
| 搜索后切换 | 输入关键词再切断点 | 关键词和筛选结果保留 |
| 详情返回 | 双栏切回单栏后返回 | 路由不乱跳,不重复打开详情 |
小结
HarmonyOS 7 / API 26 做折叠屏和多窗口适配,重点不是把页面拉宽,而是窗口变化以后状态不丢。把断点判断、页面状态、滚动恢复和日志分开,单双栏切换就会稳定很多。后面再遇到折叠屏适配问题,也能直接从断点、状态、滚动三个位置排查。
