折叠屏一展开布局还在原地,断点监听不是设一次就够
一个商品列表页,手机上单列展示,一切正常。用户把折叠屏展开,屏幕宽度从 400 左右变成 800 多,你期望列表自动从单列变成双列,右侧露出商品详情。实际现象是:列表还是单列,右侧一片空白,或者整个页面被拉伸得比例失调,图片糊了,文字被拉成横条。你重新展开、折叠几次,偶尔某一次布局变对了,再折回去又乱了。你检查 onWindowSizeChange 回调,打了日志,展开时确实触发了,宽度也确实变了。但布局没跟着变。
问题不在回调有没有触发,在于你把“窗口尺寸变了”当成了“布局会自动重排”。断点不是页面初始化时读一次就固定的值,它是一个随窗口尺寸持续变化的状态;而布局的重排,需要你在断点变化时显式地响应,不是设一个 @State 就自动生效的。更隐蔽的是,折叠屏展开时,窗口尺寸变化和页面组件的生命周期不是同步的,你在 aboutToAppear 里读到的宽度是折叠态的还是展开态的,取决于你读的时机和位置。
断点要持续监听,不是初始化时读一次
先把断点的语义钉死。HarmonyOS 的断点系统把屏幕宽度划分为 xs、sm、md、lg、xl 等区间,不同区间对应不同的布局策略。这个划分不是静态的,窗口尺寸变化时,当前断点会从一个区间迁移到另一个区间。你的布局代码需要在这个迁移发生时重新计算列数、间距、字体大小等参数。
一个很自然的错误写法是在 aboutToAppear 里读一次窗口宽度,算出断点,存进 @State,后面不再更新。折叠屏展开时,窗口宽度变了,但 @State 里的断点值没变,布局仍然按折叠态的断点渲染。你看到的现象是“宽度变了,布局没变”,根因是断点值没有跟着窗口变化更新。
正确的做法是监听窗口尺寸变化,在变化时重新计算断点。window 模块的 on('windowSizeChange') 提供了这个信号,或者在页面级用 onAreaChange 监听组件自身的尺寸变化。两者选一个,不要混用,因为它们的触发时机和坐标系不同。
typescript
import { window } from '@kit.ArkUI';
@Entry
@Component
struct ProductListPage {
@State currentBreakpoint: string = 'sm';
@State columns: number = 1;
private onSizeChange = (size: window.Size): void => {
const width = px2vp(size.width);
const bp = width >= 840 ? 'lg' : width >= 600 ? 'md' : 'sm';
if (bp !== this.currentBreakpoint) {
this.currentBreakpoint = bp;
this.columns = bp === 'lg' ? 2 : 1; // 断点变化时重算列数
}
};
aboutToAppear(): void {
const win = this.getUIContext().getHostContext() as common.UIAbilityContext;
window.getLastWindow(win).then((w) => {
const rect = w.getWindowProperties().windowRect;
this.onSizeChange({ width: rect.width, height: rect.height });
w.on('windowSizeChange', this.onSizeChange);
});
}
aboutToDisappear(): void {
window.getLastWindow(this.getUIContext().getHostContext() as common.UIAbilityContext)
.then((w) => { w.off('windowSizeChange', this.onSizeChange); });
}
}
onSizeChange 里做了两件事:更新断点值,以及基于新断点重算列数。currentBreakpoint 和 columns 都是 @State,它们的赋值会触发 UI 重排。如果你只更新了断点值,但列数是从断点值派生出来的普通变量,列数不会跟着变。断点和布局参数之间的依赖关系,必须通过状态变量来承载。
onWindowSizeChange 在折叠展开时的触发时机,不是同步的
折叠屏展开时,windowSizeChange 事件的触发和窗口内容的实际重排之间有一个时间差。你在事件回调里立刻读取组件的 width 或者 area,拿到的可能是变化前的值;你在回调里设置状态变量,UI 的重排发生在下一帧。如果你在回调里同时做了“读尺寸”和“设布局”两件事,读到的尺寸可能还是旧的。
更稳的方式是把尺寸变化和布局决策解耦:onSizeChange 只负责更新断点状态,布局参数从断点状态派生,由框架在下一帧统一重排。不要在尺寸变化回调里做同步的布局计算,也不要假设回调触发的那一刻,组件的实际尺寸已经更新完毕。
typescript
private onSizeChange = (size: window.Size): void => {
const width = px2vp(size.width);
// 只更新断点,不要在回调里做同步的布局计算
this.currentBreakpoint = width >= 840 ? 'lg' : width >= 600 ? 'md' : 'sm';
};
// 布局参数从断点派生,交给框架在合适的时机重算
@Computed get columns(): number {
return this.currentBreakpoint === 'lg' ? 2 : 1;
}
@Computed 的派生值会在断点变化后的下一次求值时重新计算,它的计算时机由框架调度,不和尺寸回调同步。这样避免了“回调里读到旧尺寸”的问题,也让布局参数的维护集中在一处。
平行视界不是自动生效,Navigation 的分栏要显式配置
折叠屏展开之后,很多应用期望的是“左侧列表、右侧详情”的平行视界布局。但 Navigation 组件默认不是分栏模式,它仍然是一个单栏的页面栈。你在折叠屏上看到列表和详情叠在一起,或者详情页盖住了列表,是因为 Navigation 的 mode 没有设置成 NavigationMode.Split。
typescript
Navigation(this.pathStack) {
// 列表内容
}
.mode(this.currentBreakpoint === 'lg'
? NavigationMode.Split // 大屏分栏
: NavigationMode.Stack) // 小屏堆叠
.navBarWidth(360)
mode 这一行不是可选的。NavigationMode.Split 把 Navigation 的内容区分为左侧导航栏和右侧内容区,navBarWidth 决定左侧的宽度。在折叠屏展开时切到 Split,折叠时切回 Stack,布局才会跟着形态变。
Split 模式下,NavPathStack 的入栈行为也有变化。列表项点击 pushPath 时,新的 NavDestination 出现在右侧内容区,左侧列表保持可见;用户点返回,右侧内容区出栈,左侧列表不变。如果你没有用 NavPathStack 管理路由,而是用 router.pushUrl 直接跳页面,Split 模式不会生效,页面仍然全屏覆盖。
悬停态是第三种形态,不是折叠或展开的二选一
折叠屏还有一个容易被忽略的形态:悬停态。设备半折叠时,屏幕被分成上下两半,上半部分和下半部分可以分别显示不同的内容。这个形态既不是“折叠”,也不是“展开”,它是一个独立的第三种状态。
display 模块的 getFoldStatus() 返回 FOLD_STATUS_FOLDED、FOLD_STATUS_EXPANDED、FOLD_STATUS_HALF_FOLDED 三个值。很多应用只处理了前两个,半折叠时按“折叠”处理,上半屏和下半屏显示同样的内容,或者下半屏被键盘占满,上半屏内容被压缩得看不清。
半折叠态的典型适配是把内容分成上下两个区域:上半屏显示内容(视频、图片、文档),下半屏显示操作区(评论、控制按钮、键盘)。FolderStack 组件提供了这种分区的布局能力,它把子组件按 upperItems 和默认区域分配到上下两半。
typescript
FolderStack({
upperItems: ['video_area'] // 上半屏显示视频,下半屏显示其他
}) {
Column() { /* 视频区域 */ }.id('video_area')
Column() { /* 控制区域 */ }
}
FolderStack 在非悬停态下退化为普通的 Stack 布局,上下两个区域叠在一起;悬停态时自动分成上下两半。你不需要为三种形态写三套布局,FolderStack 会根据折叠状态自动切换,但上半屏的内容需要你用 upperItems 显式指定。
布局重排时滚动位置和选中态要保住
折叠屏展开、折叠、再展开,布局从单列变双列、再变回单列,列表的滚动位置和选中项如果没做保存,用户会发现自己刚才看的位置不见了,列表跳回了顶部。
List 的滚动位置在布局结构变化时不会自动保持。单列变双列,列表的 item 数量没变但每行的 item 数变了,滚动偏移量和 item 索引之间的映射关系也变了,框架无法自动推算“原来在第 50 个 item 的位置,在双列布局里对应哪个偏移量”。表现是滚动位置跳变或者回到顶部。
一个务实的方式是在断点变化之前记录当前的可见 item 索引,布局重排之后用 scrollToIndex 恢复到那个索引附近。不需要精确到像素,恢复到“用户刚才在看的那一条”就足够。
typescript
private onBreakpointChanging(nextBp: string): void {
// 布局切换之前记录当前可见的第一个 item
this.savedIndex = this.listScroller.currentOffset().index;
}
private onBreakpointChanged(): void {
if (this.savedIndex >= 0) {
this.listScroller.scrollToIndex(this.savedIndex);
this.savedIndex = -1;
}
}
选中态同理。用户选中了某个商品,展开折叠屏后列表变成双列,选中态要跟着 item 走,不能因为布局变化而丢失。选中态应该存在数据模型里,而不是存在组件的局部状态里,这样布局重排时 item 重新创建,选中态从数据模型里读出来,不会丢。
configChanges 不配,展开折叠屏应用会重建
还有一个比布局重排更隐蔽的问题:折叠屏展开时,如果 UIAbility 的 configChanges 没有声明 screenSize 和 smallestScreenSize,系统会认为配置发生了重大变化,销毁并重建整个 UIAbility。用户看到的是页面闪一下,回到了首页,之前的状态全没了。
configChanges 在 module.json5 的 abilities 下声明。声明了 screenSize 之后,窗口尺寸变化不会触发 UIAbility 重建,而是走 onConfigurationUpdate 回调,你可以在回调里做布局调整,页面栈和状态都保留。
json5
{
"abilities": [
{
"name": "EntryAbility",
"configChanges": ["screenSize", "smallestScreenSize", "orientation"]
}
]
}
screenSize 覆盖折叠展开引起的窗口尺寸变化,smallestScreenSize 覆盖更极端的尺寸变化。声明了这两个之后,折叠展开不再重建 Ability,布局调整在 onConfigurationUpdate 或窗口尺寸回调里做,用户体验是连续的。
排查顺序:先看 configChanges,再看断点监听,最后看布局结构
下次折叠屏展开后布局不对、页面闪回、或者滚动位置丢失的时候,按这个顺序走。
第一步,检查 module.json5 的 configChanges 有没有声明 screenSize。没声明的话,展开折叠屏会重建 UIAbility,页面栈和状态全丢,表现是“闪一下回到首页”。这是最先要排除的。
第二步,确认断点是在窗口尺寸变化回调里持续更新的,不是 aboutToAppear 里读一次就固定的。折叠展开时打日志,看 currentBreakpoint 有没有从 sm 变成 lg,没有变就是监听没生效。
第三步,确认布局参数(列数、间距、Navigation 的 mode)是从断点状态派生的,不是写死的。断点变了但列数没变,检查列数是不是普通变量而不是 @State 或 @Computed。
第四步,如果期望平行视界但没生效,检查 Navigation 的 mode 有没有切到 NavigationMode.Split,路由是不是用 NavPathStack 管理的。
第五步,如果滚动位置或选中态在展开折叠后丢失,在断点切换前记录可见 item 索引,切换后恢复;选中态存在数据模型里,不要存在组件局部状态里。
折叠屏的适配不是“设一个断点就完事”,断点是一个随窗口尺寸持续变化的状态,布局参数要跟着它重新计算;平行视界需要显式切 Navigation 的 mode,不会自动生效;悬停态是第三种形态,FolderStack 能帮你分区但 upperItems 要显式指定;configChanges 不声明 screenSize,展开折叠屏会重建整个 Ability,状态全丢。下一次折叠屏展开布局纹丝不动的时候,先别怀疑断点值算错了——打开 module.json5 看一眼,那个 screenSize,到底有没有声明在 configChanges 里。

更多推荐

所有评论(0)