【HarmonyOS 7 悬浮页签深度实战】07 折叠屏、平板与宽窗口的布局适配
文章目录
前言
多设备适配有一个很容易产生误判的地方:我们平时习惯说手机布局、平板布局、折叠屏布局,说久了以后,很自然就会在代码里按照设备名称写分支。
实际项目跑起来以后,这套思路很快就会遇到麻烦。一台平板全屏运行的时候,横向空间可能非常充足;进入左右分屏以后,应用真正拿到的宽度却可能只有几百 vp。折叠屏也是一样,展开以后屏幕确实更大,但应用还可能进入分屏、自由窗口或者横屏状态。此时继续问当前是不是平板、是不是折叠屏,已经不能准确回答页面应该排列成一列、两列还是三列。
我自己处理这类页面时,现在更愿意把设备名称留在测试记录里,把当前可用空间真正交给布局逻辑。原因并不复杂。布局最后处理的是页面眼前还剩多少地方,而不是设备包装盒上写了什么名字。同一台设备的窗口可以不断变化,相同宽度的窗口也可能出现在完全不同的设备上。只要页面能够依据当前容器重新排列,很多设备差异其实可以落回到同一套规则里面。
HarmonyOS 的多设备设计思路本身也在强调这一点。窗口尺寸、屏幕方向和分屏状态发生变化以后,页面应该重新调整信息排列;折叠屏从折叠态进入展开态以后,还需要保持应用状态连续,同时利用新增空间显示更多有价值的信息。
这里有个细节我觉得特别值作为重点:大屏适配也不等于把手机页面横向拉长。
三张卡片在手机上纵向排列没有问题,到了展开态折叠屏或者平板,如果每张卡片仍然占满整行,用户得到的可能只是更长的文字行、更大的空白和更远的操作距离。屏幕面积增加以后,更合理的方向通常是重新组织信息,例如增加卡片列数、增加辅助区域、限制正文最大宽度,或者在适合的场景下改成分栏。HarmonyOS 当前的设计案例里也大量采用了挪移、重复、分栏等方式利用宽屏空间,而不是单纯把原有组件拉伸。
我们的 Demo 就按照这个思路处理:ContainerReader 负责观察当前页面真正拥有多少空间,内容区域根据宽度调整列数和边距;HdsTabs 继续维护自己的悬浮导航宽度。窗口发生变化的时候,布局可以重新排列,但当前 Tab 和业务状态继续保留。
我目前手里还没有可以测试 HarmonyOS 7 的真机,所以这类窗口变化现在主要通过 HarmonyOS 7 模拟器验证。折叠过程、真实分屏体验、触控距离以及不同设备上的最终表现,还是要以实机运行结果为准。

一、先确认页面还有多少空间,再决定内容如何排列
如果只在手机上做过页面,这一步很容易被忽略。
手机应用大部分时间都接近全屏运行,因此设备宽度和应用窗口宽度基本能够对应起来。到了平板、折叠屏和 2in1,这种关系开始松动。应用可能占满整个屏幕,也可能只使用其中一部分,用户还可以继续拖动窗口。华为近期关于自由多窗的排查资料里,页面截断、组件挤压和堆叠就是典型问题,窗口状态和尺寸变化都需要进入布局判断。
所以这个 Demo 没有去问当前设备是什么类型,而是直接读取页面容器。
ContainerReader 在这里很适合做这件事情。它处理的是组件自身拿到的空间,而不局限于整个物理屏幕。Demo 把它放在页面根部,所以读到的就是当前页面可用宽高;以后如果把同样的组件放到 Navigation 右侧内容区,或者放到某个分栏内部,它得到的尺寸自然会跟着局部区域变化。
这个区别对组件化开发很有用。
假设一个工作台组件在手机页面中占满屏幕,进入平板以后被放到右侧区域。如果它依赖整块屏幕宽度,组件可能觉得自己已经进入宽屏状态,实际分配给它的区域却只有五百多 vp,最后三列卡片被硬塞进去。使用容器自己的尺寸以后,这个组件不需要知道外面到底是手机、平板还是折叠屏,只需要回答眼前这块地方能不能容纳下一列。
我们的 Demo 目前配置了 440、600 和 840 三个宽度阈值,把内容划分成四种状态:
| 可用容器宽度 | 页面排列 | 主要考虑 |
|---|---|---|
| 小于 440vp | 单列,边距更紧凑 | 保证卡片和文字仍然完整 |
| 440vp~600vp | 单列,适当增加边距 | 空间增加,但暂时不足以稳定容纳双列 |
| 600vp~840vp | 双列 | 利用新增横向空间提高一屏信息量 |
| 840vp 及以上 | 三列 | 大尺寸窗口中增加内容密度,并限制整体宽度 |
这些数字在 Demo 里承担的是观察断点的作用,不是某类设备的固定规格。
真正进入业务项目以后,我更习惯从卡片本身反推断点。先确定一张卡片至少需要多少宽度,文字在什么尺寸下不会挤压,里面的按钮能不能正常操作,然后再确定什么时候适合增加第二列、第三列。这样断点和页面内容之间存在明确关系,后面即使更换设备,也不需要重新解释为什么这里一定是 600vp。
ContainerReader 的状态绑定也需要保持单向思路。size 是容器根据真实布局回写出来的结果,业务拿它决定内容怎么排列;业务不能反过来修改 size,期待容器跟着变大。父布局先给出约束,ContainerReader 再告诉子页面最后获得了多少空间,这个方向如果弄反,很容易出现循环依赖。
这里也能看出来,响应式布局真正麻烦的地方往往不是 API 数量,而是谁负责决定尺寸。
父容器负责提供空间,当前页面读取空间以后决定布局,业务状态只保存用户正在做什么。三个责任分开以后,折叠和分屏带来的变化会简单很多。
二、内容区域和悬浮页签各自按照自己的尺寸规则变化
这个 Demo 里其实同时存在两套响应式判断。
第一套属于业务内容。ContainerReader 决定卡片是一列、两列还是三列,也会调整页面边距和最大内容宽度。
第二套属于 HdsTabs。悬浮 TabBar 自己还有 smallWidth、mediumWidth 和 largeWidth 三档宽度规则,组件会根据自身尺寸选择合适的悬浮栏宽度。HdsTabs 当前在 Phone、PC/2in1 和 Tablet 上都可以正常调用,TV 则存在设备行为差异。
这两套规则放在同一个页面以后,很容易产生一个诱惑:干脆定义一个全局的小屏、中屏、大屏状态,然后所有组件一起跟着切换。
我一般不会这么处理。
内容什么时候适合从一列变成两列,取决于卡片的最小宽度;悬浮页签什么时候使用另一档宽度,则属于 HdsTabs 自己的组件规则。尤其 600vp~840vp 这一段,HdsTabs 还会结合自身高宽比进行判断。页面内容已经进入双列,并不代表 TabBar 一定只能使用某个固定档位。
让各自依据真实尺寸处理,反而更省事情。Demo 给悬浮页签设置了 228、300 和 328vp 三档宽度。这里选择比较克制,主要是因为底部只有首页、任务、我的三个短标题。窗口扩大以后,我不希望这三个入口跟着铺满整个屏幕,那样图标之间会留下很长的空白,底栏也越来越不像一个完整的导航区域。
内容区域则需要主动利用新增空间。
手机状态下六张卡片纵向排列非常正常。窗口来到六七百 vp 左右以后,两列已经能让用户同时浏览更多任务;再继续增加到更宽的窗口,三列会比单张大卡片横跨整个页面舒服很多。等窗口继续扩大,Demo 又给内容增加了 1120vp 的最大宽度,不再无限向左右伸展。
这个 1120vp 同样只是 Demo 的实验值。我觉得宽屏页面判断最大宽度时,一个非常实用的方法就是继续拖动窗口,观察页面什么时候已经没有新的信息收益。如果窗口从一千多 vp 继续增加,卡片没有增加新的列数,只是内部空白越来越多,标题和按钮相隔越来越远,那么继续拉伸就没有太大意义了。此时把内容固定在合适宽度,剩余区域作为左右留白,往往更容易阅读。
这一点和当前 HarmonyOS 的大屏设计案例也比较一致。宽屏以后常见的处理包括卡片重复排列、上下结构变成左右结构、列表和详情同时展示等,目标都是让增加出来的空间承载新的信息或者新的任务关系。
所以做大屏适配的时候,我现在会多问一句:
这块新增空间到底准备放什么?
如果答案只是把原来的组件变宽,通常还值得继续调整。

三、窗口变化以后业务状态被一起重建
页面从一列变成两列,本身并没有多复杂。真正让我比较警惕的是另一件事情:用户可能正在页面里做事。
假设用户已经切换到任务 Tab,填写了一半表单,然后把折叠屏展开。或者正在平板分屏里处理任务,顺手拖动分割线,让窗口从 580vp 变成 650vp。布局跨过断点以后当然应该重新排列,但用户刚才输入的内容、当前 Tab、筛选条件、滚动位置,都不应该跟着回到初始状态。
这也是折叠屏设计里经常强调的体验连续性。屏幕尺寸和形态可以发生变化,应用需要继续正常运行,并且保留用户当前任务。
Demo 里面始终保留同一个 HdsTabsController 和同一个 currentIndex。ContainerReader 更新的是容器尺寸和布局档位,卡片根据新的宽度重新排列;用户现在位于首页、任务还是我的,属于另一层状态,不需要因为列数变化重新创建。这种分法在小 Demo 里面看起来有点刻意,到了真实项目里却很重要。
我自己做响应式页面时,通常会把状态粗略分成两类。窗口宽度、列数、边距属于布局状态,它们可以随着容器变化不断更新;表单内容、当前任务、播放进度、选中项目属于业务状态,它们应该保存在更加稳定的位置。
一旦把两类状态绑在一起,问题会变得非常难查。比如某个开发者为了切换宽屏布局,直接用条件判断创建两套完全不同的页面结构。窗口跨过断点以后,旧页面被销毁,新页面重新创建,视觉上确实变成了两列,用户刚才填写的内容也一起没有了。代码每一部分看起来都正确,体验却完全断掉。
折叠屏还会进一步放大这个问题,因为折叠和展开可能发生得非常频繁。用户不会认为自己重新打开了一次应用,他只是把设备展开了。页面如果每次都重新请求数据、恢复到首页或者丢失滚动位置,很容易让人产生应用不稳定的感觉。
所以形态变化的回调也不适合承担所有事情。
窗口尺寸变化负责触发布局重新排列;只有真正依赖物理折叠状态的业务,例如跨折痕内容或者悬停交互,才需要额外关注折叠形态。普通列表没有必要因为设备展开就重新请求一次网络数据。
这种处理还有一个额外好处:以后导航形态真的发生变化,业务状态也更容易留下来。
例如窄窗口继续使用底部 HdsTabs,宽窗口改成左侧导航。如果两边共用同一份选中状态和业务数据,变化的只是导航入口放在哪里;如果两套导航各自维护页面状态,窗口来回调整几次以后,很容易出现一边停在任务页、一边还以为自己在首页的情况。

四、宽窗口到底要不要换成侧边导航,要看页面本身
到了八九百 vp 以上,有一个问题通常会出现:底部导航还要继续保留吗?这个问题没有必要预先绑定设备类型。
三个一级入口的轻量应用,即使放到较大的折叠屏或者平板上,底部悬浮页签仍然可能很好用。用户已经习惯在这三个区域之间频繁切换,导航本身也没有占用太多空间,强行搬到侧边未必得到明显收益。
另一类页面就完全不同。如果应用有五六个主入口,还有树状目录、文件列表、工具面板,用户在宽屏环境下又经常使用鼠标和键盘,那么侧边导航通常更加合适。屏幕足够宽以后,左侧可以保留稳定导航,右侧同时展示更多业务信息。这也是当前多设备设计中非常常见的宽屏处理方式。
HdsTabs 这里还存在一个技术边界。当前悬浮形态本身依赖横向底部布局。HdsTabs 的 vertical、barPosition、barOverlap 和 barFloatingStyle 分别控制页签方向、位置和悬浮关系。真正切换成侧边导航以后,已经进入另一种导航形态,不能把原来的悬浮样式原封不动搬到左侧。
因此 Demo 有意没有在 840vp 自动切换侧栏。
这样处理并不是说宽屏只能保留底部页签,而是为了把实验变量控制住。这一页重点观察的是同一个 HdsTabs 在连续窗口变化时,业务内容怎么重新排列、导航状态能不能稳定保留。如果同时把导航容器也切换掉,调试的时候很难判断问题到底来自内容断点,还是导航重建。
正式项目可以再根据自己的信息架构决定。我的判断通常比较简单:入口少、触控频繁、业务层级浅,可以继续评估底部悬浮;入口变多、层级增加、宽屏常驻使用明显,就值得评估侧边导航。这里真正需要判断的是页面的使用方式,不是设备是不是平板。
这也是为什么我不太喜欢写成平板使用侧栏、手机使用底栏。
同一台平板缩到分屏以后,原来非常合适的侧栏可能重新挤压内容;同一个 2in1 窗口也可以被用户缩到手机差不多的尺寸。按照窗口和业务空间判断,逻辑会稳定得多。
最后的回归也建议沿着同一个窗口连续调整,而不是只准备手机、平板、折叠屏三张静态截图。
我一般会先停在一个能够识别状态的页面,例如任务 Tab,再缓慢拖动窗口,连续经过 440、600、840 和内容最大宽度附近。卡片什么时候增加列数、页边距有没有突然变化、当前 Tab 有没有丢失、最后一个操作项还能不能滚到悬浮栏上方,这些问题在连续拖动时最容易暴露。
断点附近尤其值得多来回几次。如果 599vp 和 601vp 之间出现非常剧烈的视觉变化,说明两个档位之间的设计差异可能过大;如果窗口稍微移动一下,布局就在两个状态之间反复跳动,还需要继续检查尺寸来源和临界判断。
模拟器很适合完成这种连续窗口实验,也方便反复进入分屏和自由窗口。真正涉及物理折叠、铰链位置、实际触控距离、鼠标键盘和硬件窗口行为时,最终还是要回到对应设备。HarmonyOS 当前的多设备资料同样把手机、折叠屏、平板等设备放在不同窗口形态和交互条件下分别考虑。
总结
我现在做多设备页面时,会尽量减少一个习惯:看到平板就写一套布局,看到折叠屏再写另外一套。
更容易维护的方式,是先确认当前内容区域到底有多少可用空间,再决定卡片、边距和导航应该怎么排列。ContainerReader 在这里负责业务内容,HdsTabs 继续按照自己的组件规则处理悬浮页签宽度。两个区域都根据真实尺寸变化,各自承担自己的职责,没有必要强行共享一个所谓大屏状态。
窗口继续扩大以后,页面也要开始思考如何真正使用这些新增空间。卡片可以增加列数,内容可以增加辅助区域,阅读页面可以限制正文宽度,复杂业务还可以进入分栏。简单把手机页面横向撑满,通常只是让页面变大,并没有让它更适合大屏。
更需要守住的是任务连续性。折叠、展开、分屏和自由窗口都属于用户正常使用设备的过程,布局可以不断变化,当前 Tab、表单内容、筛选条件和滚动状态应该继续存在。把布局状态和业务状态分开以后,后面无论继续使用底部 HdsTabs,还是在宽窗口改成侧边导航,都会容易很多。
我目前手里还没有可以测试 HarmonyOS 7 的真机,所以现在只能先通过 HarmonyOS 7 模拟器检查窗口变化、内容重排和基础交互。折叠过程、真实分屏体验、输入设备差异以及最终视觉效果,还是要以实际设备运行结果为准。
完整代码
Main.ets
/**
* HarmonyOS 7 悬浮页签深度实战 07
*
*/
import {
ContainerReader,
ContainerReaderAttribute as _ContainerReaderAttribute,
Size
} from '@kit.ArkUI';
import {
HdsTabs,
HdsTabsController,
HdsTabsFloatingStyle
} from '@kit.UIDesignKit';
@Entry
@Component
struct Main {
private tabsController: HdsTabsController =
new HdsTabsController();
@State private containerSize: Size = { width: 0, height: 0 };
@State private widthBreakpoint: WidthBreakpoint =
WidthBreakpoint.WIDTH_XS;
@State private currentIndex: number = 0;
private cardTitles: string[] = [
'待处理任务',
'本周进度',
'协作消息',
'最近文档',
'常用服务',
'项目提醒'
];
private tabName(index: number): string {
if (index === 1) {
return '任务';
}
if (index === 2) {
return '我的';
}
return '首页';
}
private breakpointName(): string {
if (this.widthBreakpoint === WidthBreakpoint.WIDTH_XS) {
return '超窄布局';
}
if (this.widthBreakpoint === WidthBreakpoint.WIDTH_SM) {
return '窄布局';
}
if (this.widthBreakpoint === WidthBreakpoint.WIDTH_MD) {
return '中宽布局';
}
return '宽布局';
}
private columnCount(): number {
if (this.widthBreakpoint === WidthBreakpoint.WIDTH_XS ||
this.widthBreakpoint === WidthBreakpoint.WIDTH_SM) {
return 1;
}
if (this.widthBreakpoint === WidthBreakpoint.WIDTH_MD) {
return 2;
}
return 3;
}
private columnsTemplate(): string {
if (this.columnCount() === 1) {
return '1fr';
}
if (this.columnCount() === 2) {
return '1fr 1fr';
}
return '1fr 1fr 1fr';
}
private gridHeight(): number {
if (this.columnCount() === 1) {
return 708;
}
if (this.columnCount() === 2) {
return 348;
}
return 228;
}
private horizontalPadding(): number {
if (this.widthBreakpoint === WidthBreakpoint.WIDTH_XS) {
return 12;
}
if (this.widthBreakpoint === WidthBreakpoint.WIDTH_SM) {
return 20;
}
return 24;
}
private contentWidth(): Length {
if (this.containerSize.width >= 1120) {
return 1120;
}
return '100%';
}
private floatingStyle(): HdsTabsFloatingStyle {
return {
barWidth: {
smallWidth: 228,
mediumWidth: 300,
largeWidth: 328
},
barBottomMargin: 20,
gradientMask: {
maskColor: '#66F4F6FB',
maskHeight: 112
}
};
}
@Builder
private sizeBadge(label: string, value: string) {
Column({ space: 3 }) {
Text(label)
.fontSize(11)
.fontColor('#68708A')
Text(value)
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor('#17203A')
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.backgroundColor(Color.White)
.borderRadius(14)
.alignItems(HorizontalAlign.Center)
}
@Builder
private page(title: string, description: string) {
Scroll() {
Row() {
Column({ space: 14 }) {
Column({ space: 5 }) {
Text(title)
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#11182C')
.width('100%')
Text(description)
.fontSize(13)
.fontColor('#68708A')
.lineHeight(19)
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
Grid() {
ForEach(
this.cardTitles,
(item: string, index: number) => {
GridItem() {
Column({ space: 8 }) {
Row() {
Text(`${index + 1}`)
.fontSize(12)
.fontColor(Color.White)
}
.width(28)
.height(28)
.justifyContent(FlexAlign.Center)
.backgroundColor('#5065E8')
.borderRadius(9)
Text(item)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor('#17203A')
.width('100%')
Text(`在 ${this.breakpointName()} 中占据一格`)
.fontSize(11)
.fontColor('#68708A')
.width('100%')
}
.width('100%')
.height('100%')
.padding(14)
.backgroundColor(Color.White)
.borderRadius(18)
.alignItems(HorizontalAlign.Start)
}
},
(item: string) => item
)
}
.columnsTemplate(this.columnsTemplate())
.columnsGap(12)
.rowsGap(12)
.width('100%')
.height(this.gridHeight())
Row() {
Text('当前 Tab')
.fontSize(12)
.fontColor('#68708A')
Blank()
Text(this.tabName(this.currentIndex))
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor('#5065E8')
}
.width('100%')
.padding(16)
.backgroundColor('#E9EDFF')
.borderRadius(18)
}
.width(this.contentWidth())
.padding({
left: this.horizontalPadding(),
right: this.horizontalPadding(),
top: 16,
bottom: 132
})
}
.width('100%')
.justifyContent(FlexAlign.Center)
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
.backgroundColor('#F4F6FB')
}
build() {
ContainerReader({
size: this.containerSize!!,
widthBreakpoint: this.widthBreakpoint!!
}) {
Column({ space: 10 }) {
Column({ space: 10 }) {
Row() {
Column({ space: 4 }) {
Text('可变窗口工作台')
.fontSize(25)
.fontWeight(FontWeight.Bold)
.fontColor('#11182C')
.width('100%')
Text('按当前可用容器重排,不读取设备名称')
.fontSize(13)
.fontColor('#68708A')
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text(this.breakpointName())
.fontSize(12)
.fontColor('#5065E8')
.padding({ left: 10, right: 10, top: 7, bottom: 7 })
.backgroundColor('#E9EDFF')
.borderRadius(12)
}
.width('100%')
Row({ space: 8 }) {
this.sizeBadge(
'可用容器',
`${this.containerSize.width.toFixed(0)} × ${this.containerSize.height.toFixed(0)} vp`
)
this.sizeBadge('内容列数', `${this.columnCount()} 列`)
this.sizeBadge('导航形态', '底部悬浮')
}
.width('100%')
Text('TabBar 配置:228 / 300 / 328vp,实际选档由 HdsTabs 根据自身尺寸处理')
.fontSize(11)
.fontColor('#68708A')
.width('100%')
}
.width('100%')
.padding({
left: this.horizontalPadding(),
right: this.horizontalPadding(),
top: 18
})
.alignItems(HorizontalAlign.Start)
Column() {
HdsTabs({
controller: this.tabsController
}) {
TabContent() {
this.page('首页', '窗口变化时,卡片重排,页签索引保持不变。')
}
.tabBar(
new BottomTabBarStyle(
$r('sys.media.ohos_ic_public_clock'),
'首页'
)
)
TabContent() {
this.page('任务', '分屏与自由窗口也使用当前可用宽度。')
}
.tabBar(
new BottomTabBarStyle(
$r('sys.media.ohos_ic_public_clock'),
'任务'
)
)
TabContent() {
this.page('我的', '宽窗口限制内容宽度,避免卡片无限拉长。')
}
.tabBar(
new BottomTabBarStyle(
$r('sys.media.ohos_ic_public_clock'),
'我的'
)
)
}
.barPosition(BarPosition.End)
.vertical(false)
.scrollable(true)
.barOverlap(true)
.animationDuration(240)
.barFloatingStyle(this.floatingStyle())
.onChange((index: number) => {
this.currentIndex = index;
})
.width('100%')
.height('100%')
}
.width('100%')
.layoutWeight(1)
}
.width('100%')
.height('100%')
.backgroundColor('#F4F6FB')
}
.breakpointConfig({
width: [440, 600, 840]
})
.width('100%')
.height('100%')
}
}
更多推荐



所有评论(0)