折叠屏适配踩坑实录:从"一展开就变形"到一套代码适配多种形态【鸿蒙心迹】
写在前面
资讯应用上线后,一条折叠屏反馈戳中了我:展开大屏浏览时,图片被拉变形,页面两侧空荡荡留着大片白,一眼就能看出是手机布局直接硬拉伸到大屏上。 上手真机复现,问题很明确:折叠小屏状态表现没问题;展开后单列布局占满全宽,封面图等比约束失效、出现拉伸;详情文本单行过长,阅读起来很吃力。 花了两周时间,把多设备形态适配重新梳理一轮,踩了不少坑。下面以「现象 - 原因 - 解法」的思路记录,附带项目抽出来的精简 ArkTS 代码(Stage 模型),希望能帮做折叠屏适配的伙伴少踩雷。
坑一:用屏幕宽度判断布局
最初版本的写法很直接:
typescript
aboutToAppear(): void {
const screenWidth = px2vp(display.getDefaultDisplaySync().width);
this.columns = screenWidth >= 600 ? 2 : 1;
}
这段代码有两个问题。第一个比较明显:只在 aboutToAppear 里算一次,折叠或展开后根本不会刷新。
第二个更隐蔽:它取的是屏幕宽度 ,不是窗口宽度。展开态下进入分屏或悬浮窗时,屏幕依然很宽,但应用窗口可能只有一半甚至更小,结果在一个窄窗口里排出了双列,卡片挤成一团。
结论是:布局应该以应用窗口尺寸为准,而不是屏幕尺寸,更不是设备类型。我们参考"一次开发,多端部署"的思路,按窗口宽度划分断点:
| 断点 | 窗口宽度 | 典型场景 |
|---|---|---|
| xs | < 320vp | 极窄的悬浮窗 |
| sm | 320vp ~ 600vp | 直板机、折叠屏折叠态 |
| md | 600vp ~ 840vp | 折叠屏展开态、较大的分屏窗口 |
| lg | ≥ 840vp | 平板、2in1 设备 |
坑二:只监听折叠状态
第二版我改成了监听 display.on('foldStatusChange')。折叠和展开确实能正确响应了,但测试很快发现:分屏、悬浮窗缩放、横竖屏切换时,窗口尺寸变了,折叠状态却没变,回调根本不触发。
这时候我才意识到:折叠状态和布局是两回事 。布局真正关心的只有"我的窗口现在多宽"。所以最终方案是在 EntryAbility 里统一监听 windowSizeChange,算出断点后写入 AppStorage,所有页面共用:
typescript
import { UIAbility } from '@kit.AbilityKit';
import { display, window } from '@kit.ArkUI';
export default class EntryAbility extends UIAbility {
private mainWindow?: window.Window;
// 按窗口宽度(vp)计算断点,只有断点真正变化时才写入 AppStorage
private updateBreakpoint(windowWidthPx: number): void {
const density = display.getDefaultDisplaySync().densityPixels;
const widthVp = windowWidthPx / density;
let bp: string = 'lg';
if (widthVp < 320) {
bp = 'xs';
} else if (widthVp < 600) {
bp = 'sm';
} else if (widthVp < 840) {
bp = 'md';
}
if (AppStorage.get<string>('currentBreakpoint') !== bp) {
AppStorage.setOrCreate<string>('currentBreakpoint', bp);
}
}
onWindowStageCreate(windowStage: window.WindowStage): void {
try {
this.mainWindow = windowStage.getMainWindowSync();
// 首次进入先算一次,避免页面先用默认值渲染再"闪"一下
this.updateBreakpoint(this.mainWindow.getWindowProperties().windowRect.width);
this.mainWindow.on('windowSizeChange', (size: window.Size) => {
this.updateBreakpoint(size.width);
});
} catch (err) {
console.error(`init breakpoint failed: ${JSON.stringify(err)}`);
}
windowStage.loadContent('pages/Index');
}
onWindowStageDestroy(): void {
this.mainWindow?.off('windowSizeChange');
}
}
这里有两个小细节。一是 windowSizeChange 回调里的宽度单位是 px,必须换算成 vp 再比较,否则高密度屏上断点会整体偏移。二是只在断点真正变化时才写 AppStorage,因为拖动悬浮窗边缘时这个回调触发得非常频繁,每次都写会导致整页反复刷新。
页面里用一行就能拿到当前断点:
typescript
@StorageProp('currentBreakpoint') currentBreakpoint: string = 'sm';
如果你的项目最低支持版本在 API 13 及以上,也可以直接用 UIContext.getWindowWidthBreakpoint() 获取窗口宽度断点。我们因为要兼容旧版本,保留了自己维护的方案。
坑三:列数切换后,滚动位置"瞬移"
断点有了,首页列表改成根据断点切换 1 / 2 / 3 列。这时测试同学报了一个奇怪的问题:在折叠态滑到第 30 条左右,展开后,屏幕上显示的却是第 60 条附近的内容。
原因是列数切换后 List 重新布局,内容总高度大约变成了原来的一半,但列表保留的是原来的滚动偏移量。同样的偏移量在双列布局里对应的已经是完全不同的内容。
解法是记录"语义位置"而不是像素位置:用 onScrollIndex 记下首个可见项的索引,断点变化后再用 scrollToIndex 定位回去。
typescript
@Component
export struct NewsList {
@StorageProp('currentBreakpoint') @Watch('onBreakpointChange') currentBreakpoint: string = 'sm';
@Prop newsList: NewsItem[] = [];
private scroller: Scroller = new Scroller();
private firstVisibleIndex: number = 0;
onBreakpointChange(): void {
const target = this.firstVisibleIndex;
// 等新的列数布局生效后,再按"索引"把位置找回来
setTimeout(() => {
this.scroller.scrollToIndex(target);
}, 0);
}
private getLanes(): number {
if (this.currentBreakpoint === 'lg') {
return 3;
}
if (this.currentBreakpoint === 'md') {
return 2;
}
return 1;
}
build() {
List({ space: 12, scroller: this.scroller }) {
ForEach(this.newsList, (item: NewsItem) => {
ListItem() {
NewsCard({ item: item })
}
}, (item: NewsItem) => item.id)
}
.lanes(this.getLanes(), 12)
.onScrollIndex((start: number) => {
this.firstVisibleIndex = start;
})
.padding({ left: 16, right: 16 })
}
}
注意 setTimeout 这一步不能省。@Watch 回调触发时新布局还没完成,立刻调用 scrollToIndex 会按旧布局去定位,效果时好时坏。
坑四:图片变形与正文过宽
封面图的问题出在原来的写法 .width('100%').height(180).objectFit(ImageFit.Fill)。当初为了"填满"用了 Fill,展开后宽度翻倍、高度不变,图片自然被横向拉伸。改成固定宽高比加 Cover 之后,任何宽度下都不会变形:
typescript
Image(this.item.coverUrl)
.width('100%')
.aspectRatio(16 / 9)
.objectFit(ImageFit.Cover)
.borderRadius(8)
详情页正文过宽的问题,用栅格布局限制内容区宽度最省事:小屏占满,大屏居中并留出两侧空白。
typescript
GridRow({
columns: { xs: 4, sm: 4, md: 8, lg: 12 },
gutter: { x: 16 },
breakpoints: { value: ['320vp', '600vp', '840vp'], reference: BreakpointsReference.WindowSize }
}) {
GridCol({
span: { xs: 4, sm: 4, md: 6, lg: 8 },
offset: { xs: 0, sm: 0, md: 1, lg: 2 }
}) {
ArticleContent({ article: this.article })
}
}
这里我还踩了一个小坑:一开始我把 breakpoints.value 写成了 ['600vp', '840vp'],结果 md 的配置死活不生效。原因是 GridRow 的断点名是按顺序从 xs 开始分配的,两个分界值会切出三段,分别叫 xs、sm、md,而不是 sm、md、lg。断点名和自己 AppStorage 里的命名对不上,排查了半天。建议要么直接用默认值,要么像上面一样把分界值写全。
进阶:视频页的悬停态
折叠屏半折叠放在桌面上(悬停态)时,最理想的体验是视频在上半屏播放、控制栏和评论在下半屏。这个效果用 FolderStack 实现起来不复杂:把需要放到上半屏的子组件 id 填进 upperItems,其余组件在悬停时会被放到下半屏。
typescript
@Component
struct VideoPage {
@State isHalfFolded: boolean = false;
build() {
FolderStack({ upperItems: ['videoArea'] }) {
Column() {
VideoPlayerView()
}
.id('videoArea')
Column() {
PlayControlBar({ compact: !this.isHalfFolded })
CommentList()
}
}
.onFolderStateChange((event) => {
this.isHalfFolded = event.foldStatus === FoldStatus.FOLD_STATUS_HALF_FOLDED;
})
.width('100%')
.height('100%')
}
}
这里有个容易忽略的点:非悬停状态下,FolderStack 的表现就是一个普通的 Stack,子组件会叠在一起。所以下半区的内容要按"浮层"的思路来设计,否则平时看起来会是视频和评论区重叠在一起。另外建议让 FolderStack 撑满整个窗口,并且一定要上真机做横向半折叠验证。
自测清单
适配完成后,我整理了一份每次发版前都会过一遍的清单:
- 折叠 ↔ 展开来回切换 10 次以上,布局正确、页面状态不丢失
- 展开态下分屏(不同比例)和悬浮窗拖拽缩放,断点切换正常
- 列表滑到中部后切换形态,阅读位置保持不变
- 视频播放中折叠或展开,播放不中断、不重新加载
- 键盘弹出时切换形态,输入框不被遮挡
- 横竖屏切换、深色模式、大字体下的展开态显示正常
- 悬停态下上下半屏内容分区正确
总结
回头看,这次适配的核心经验可以浓缩成三句话。
第一,以窗口为准,不以屏幕为准。分屏、悬浮窗、平行视界都会让窗口和屏幕不一致,只看窗口宽度才不会出错。
第二,断点统一管理,只在变化时通知。在 Ability 层集中监听、集中计算,页面只消费结果,既避免了重复代码,也避免了高频刷新。
第三,状态按语义保存。滚动位置记索引不记偏移,图片记比例不记高度,形态切换时才不会"瞬移"和"变形"。
折叠屏适配看起来是 UI 问题,本质上是"让布局跟着窗口走"的思维转变。想通这一点之后,后面新增的页面基本都是一次写好,多种形态直接可用。