前言

多设备适配有一个很容易产生误判的地方:我们平时习惯说手机布局、平板布局、折叠屏布局,说久了以后,很自然就会在代码里按照设备名称写分支。

实际项目跑起来以后,这套思路很快就会遇到麻烦。一台平板全屏运行的时候,横向空间可能非常充足;进入左右分屏以后,应用真正拿到的宽度却可能只有几百 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 自己还有 smallWidthmediumWidthlargeWidth 三档宽度规则,组件会根据自身尺寸选择合适的悬浮栏宽度。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 和同一个 currentIndexContainerReader 更新的是容器尺寸和布局档位,卡片根据新的宽度重新排列;用户现在位于首页、任务还是我的,属于另一层状态,不需要因为列数变化重新创建。这种分法在小 Demo 里面看起来有点刻意,到了真实项目里却很重要。

我自己做响应式页面时,通常会把状态粗略分成两类。窗口宽度、列数、边距属于布局状态,它们可以随着容器变化不断更新;表单内容、当前任务、播放进度、选中项目属于业务状态,它们应该保存在更加稳定的位置。

一旦把两类状态绑在一起,问题会变得非常难查。比如某个开发者为了切换宽屏布局,直接用条件判断创建两套完全不同的页面结构。窗口跨过断点以后,旧页面被销毁,新页面重新创建,视觉上确实变成了两列,用户刚才填写的内容也一起没有了。代码每一部分看起来都正确,体验却完全断掉。

折叠屏还会进一步放大这个问题,因为折叠和展开可能发生得非常频繁。用户不会认为自己重新打开了一次应用,他只是把设备展开了。页面如果每次都重新请求数据、恢复到首页或者丢失滚动位置,很容易让人产生应用不稳定的感觉。

所以形态变化的回调也不适合承担所有事情。

窗口尺寸变化负责触发布局重新排列;只有真正依赖物理折叠状态的业务,例如跨折痕内容或者悬停交互,才需要额外关注折叠形态。普通列表没有必要因为设备展开就重新请求一次网络数据。

这种处理还有一个额外好处:以后导航形态真的发生变化,业务状态也更容易留下来。

例如窄窗口继续使用底部 HdsTabs,宽窗口改成左侧导航。如果两边共用同一份选中状态和业务数据,变化的只是导航入口放在哪里;如果两套导航各自维护页面状态,窗口来回调整几次以后,很容易出现一边停在任务页、一边还以为自己在首页的情况。

四、宽窗口到底要不要换成侧边导航,要看页面本身

到了八九百 vp 以上,有一个问题通常会出现:底部导航还要继续保留吗?这个问题没有必要预先绑定设备类型。

三个一级入口的轻量应用,即使放到较大的折叠屏或者平板上,底部悬浮页签仍然可能很好用。用户已经习惯在这三个区域之间频繁切换,导航本身也没有占用太多空间,强行搬到侧边未必得到明显收益。

另一类页面就完全不同。如果应用有五六个主入口,还有树状目录、文件列表、工具面板,用户在宽屏环境下又经常使用鼠标和键盘,那么侧边导航通常更加合适。屏幕足够宽以后,左侧可以保留稳定导航,右侧同时展示更多业务信息。这也是当前多设备设计中非常常见的宽屏处理方式。

HdsTabs 这里还存在一个技术边界。当前悬浮形态本身依赖横向底部布局。HdsTabs 的 verticalbarPositionbarOverlapbarFloatingStyle 分别控制页签方向、位置和悬浮关系。真正切换成侧边导航以后,已经进入另一种导航形态,不能把原来的悬浮样式原封不动搬到左侧。

因此 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%')
  }
}
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐