写在前面的碎碎念:
上周五,QA 拿着新买的折叠屏手机跑过来,直接甩我脸上:“你自己看看,你这页面展开之后像个大号直板机就算了,点个列表进去,详情页怎么把我列表给挤没了?”
我拿过来一看,确实惨不忍睹。之前做 UI 的时候,我习惯性地用了 width('100%') 和绝对定位,在直板机上看着岁月静好,一到折叠屏展开态(大屏),元素全被拉长;更致命的是,我用了简单的 if/else 来控制列表和详情的显示,展开后逻辑直接错乱。
这周末痛定思痛,把整个页面的折叠屏适配和平行视界重写了一遍。这篇文章就当是个复盘日记,记录一下我是怎么从一个只知道写死宽度的“小白”,一步步重构出能自适应大屏的架构的。
一开始,我天真地以为只需要监听宽度
刚接手这个需求时,我寻思着折叠屏不就是屏幕变宽了吗?那我在生命周期里拿一下屏幕宽度,做个判断不就行了?
这是我第一版极其简陋的代码:
typescript
import display from '@ohos.display';
@Entry
@Component
struct GoodsPage {
@State isLargeScreen: boolean = false;
aboutToAppear() {
// 第一版:直接去拿当前屏幕宽度
const screenWidth = display.getDefaultDisplaySync().width;
// 假设 px 超过 800 算大屏
this.isLargeScreen = px2vp(screenWidth) > 800;
}
build() {
Row() {
// 如果是大屏,左右分栏;如果是小屏,只显示列表
if (this.isLargeScreen) {
ListView().width('40%')
DetailView().width('60%')
} else {
ListView().width('100%')
}
}
}
}
折叠屏折叠/展开时,aboutToAppear 根本不会重新执行。 这意味着用户如果是在应用内把手机展开,我的页面依然是直板机布局,宽宽的大屏中间挤着一条窄窄的列表,堪称灾难。
后来我去查文档,发现不能这么玩,得用 mediaquery 去监听断点变化。改造后:
typescript
import mediaquery from '@ohos.mediaquery';
@Entry
@Component
struct GoodsPage {
@State isLargeScreen: boolean = false;
// 监听宽度大于 840vp 的断点
private listener: mediaquery.MediaQueryListener = mediaquery.matchMediaSync('(width>=840vp)');
aboutToAppear() {
// 一开始先获取一次
this.isLargeScreen = this.listener.matches;
// 关键:监听折叠状态变化
this.listener.on('change', (result: mediaquery.MediaQueryResult) => {
this.isLargeScreen = result.matches;
});
}
aboutToDisappear() {
// 极其重要:组件销毁时一定要注销监听,否则会内存泄漏
this.listener.off('change');
}
// build 代码略...
}
这样改完,终于能做到随着折叠状态动态切换布局了。
平行视界不是简单的左右分栏
布局跑通后,我开始做“平行视界”。产品经理的需求是:展开状态下,左边看列表,右边看详情。
我一开始想得很简单,用两个 Column 拼在一起,点击列表的某项,改变右边 Column 里的状态变量不就行了?
代码大概长这样:
typescript
@State currentDetailId: string = '';
// 在列表的点击事件里
.onClick(() => {
this.currentDetailId = item.id;
})
// 右侧详情区
if (this.isLargeScreen) {
Column() {
if (this.currentDetailId) {
DetailView({ id: this.currentDetailId })
}
}.width('60%')
}
这又掉进大坑了。 因为右侧的 DetailView 是随着 currentDetailId 变化的。每次点击,右侧组件都会经历一次完整的“销毁 -> 重建”过程。原本在详情页里滚动的进度、填写的表单,全丢了。更糟的是,如果用户点击返回键,我是该退回列表,还是关闭整个页面?
真正标准的做法,是用 ArkUI 提供的 Navigation 组件。它不仅原生支持分栏,还完美接管了返回栈。
重构后的核心结构:
typescript
@Provide('pageStack') pageStack: NavPathStack = new NavPathStack();
build() {
Navigation(this.pageStack) {
ListView() // 这里放列表
}
.mode(this.isLargeScreen ? NavigationMode.Split : NavigationMode.Stack) // 核心!
.navBarWidth(300) // 左侧列表宽度
.hideTitleBar(true)
}
当点击列表项时,不再是去改状态变量,而是往路由栈里推一个详情页:
typescript
this.pageStack.pushPath({ name: 'DetailPage', param: item.id });
Navigation 的 Split 模式非常聪明。在大屏下,它自动把列表页和详情页左右排布;当你把手机折叠起来(变成小屏),它会自动变成单页压栈模式,详情页覆盖列表页,用户按返回键,完美回退。之前手动写状态管理的痛楚,全被它解决了。
弹窗与软键盘陷阱不断
大屏适配不仅是界面的变形,还有很多交互细节。
我的列表页里有一个“筛选”按钮,点击后弹出一个自定义的 Dialog。在直板机上没问题,但在折叠屏展开时,因为屏幕极宽,这个 Dialog 如果依然居中,会和左右两边的列表、详情重叠,极其难看。
解决方法:给弹窗设置避让区域。
typescript
// 大屏下,把弹窗限制在列表所在的左侧区域弹出
promptAction.openCustomDialog({
builder: () => { FilterDialog() },
alignment: DialogAlignment.Bottom,
// 动态计算偏移量
offset: { dx: this.isLargeScreen ? -200 : 0, dy: 0 }
})
另外一个致命 bug 是软键盘。在详情页(分栏右侧)写评论时,键盘一弹出来,直接把整个页面往上顶,左侧的列表也全跟着往上移,导致用户不知道自己在哪里。
后来加上了键盘避让的配置:
typescript
// 在 EntryAbility 的 onWindowStageCreate 中
windowStage.getMainWindow().then((win) => {
win.setWindowLayoutFullScreen(true); // 沉浸式
// 设置键盘避让模式为 RESIZE,只有当前窗口会被压缩,不会整个页面平移
win.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE);
});
最后经过反复测试,才勉强达到了及格线。真心建议各位,折叠屏测试一定要用真机,DevEco 的模拟器在某些手势和键盘交互上,和真机还是有差异。
经验总结与思考
这次折叠屏改造,折腾了我整整三天。总结几条血泪教训:
-
彻底戒掉写死尺寸的习惯。多用
layoutWeight和百分比,绝对不要写width(400)。 -
Navigation组件是神仙。不要试图用if/else去模拟多端布局,不仅逻辑混乱,还会带来严重的生命周期问题。 -
监听器一定要对生命负责。
mediaquery的on对应的一定要有off。
本文复盘HarmonyOS 7折叠屏应用中平行视界功能的重构过程,重点解决仅监听屏幕宽度导致的布局失效、手动分栏引发的组件重建与状态丢失问题,以及弹窗定位和软键盘遮挡等交互陷阱。通过采用ArkUI原生SplitLayout组件、断点监听机制及键盘避让配置,实现大屏分栏与小屏压栈的自适应路由管理,强调避免硬编码尺寸、善用声明式布局与生命周期规范。

338

被折叠的 条评论
为什么被折叠?



