前言

悬浮页签能够正常显示以后,宽度、底部距离和渐变遮罩就要开始调整了。宽度不合适,三个入口会显得拥挤或相隔太远;底部距离不合适,页签可能贴近系统导航区域,也可能与底边相距过大;遮罩的不透明度过高,正文还没靠近页签就开始变淡。

手机端窗口宽度较小时,三个入口排列在一条两百多 vp 的页签栏中,图标和文字通常还能保持紧凑。窗口宽度增加以后,如果页签栏也跟着一直伸展,三个入口之间会出现大段空白,视线和手指的移动距离也会增加。底部距离也没有一个适合所有页面的数字:同样的 28vp,在模拟器、带导航指示器的手机和可调整窗口中,有时会让页签贴近导航指示器,有时又会让它与底边相距较大。

barOverlap 开启后,TabContent 会继续延伸到 TabBar 后方。彩色卡片、图片和文字从悬浮栏下面经过时,导航图标可能被背景细节干扰;遮罩的不透明度过高或覆盖范围过大,正文又会提前淡掉。调试时一次只修改一项,三项数值确定以后,还要结合窗口宽度、滚动位置和主题检查。

barFloatingStyle 接收可选的 HdsTabsFloatingStyle,这组悬浮样式从 6.1.0(23) 开始提供。它要求页签位于底部 barPosition=BarPosition.End、保持横向排列 vertical=false,同时开启内容覆盖 barOverlap=true;页签内容采用 BottomTabBarStyleCustomBuilder。缺少任何一项,继续调整宽度、间距或遮罩也无法解决结构问题。

一、宽度跟着窗口变化

三个入口在手机宽度下排列正常,窗口宽度增加后,悬浮栏可能同步伸展为横向尺寸过大的页签栏,图标之间留下大段空白。barFloatingStyle 里的 barWidth 用于限制悬浮 TabBar 自身的宽度,类型是 HdsBarWidthRangeOptions,其中包含 smallWidthmediumWidthlargeWidth 三档。Demo 在一个对象里同时设置三档值,HdsTabs 根据组件所处的宽度区间选择一档。HdsTabs 自身还有接收 Length 的普通 barWidth 属性,两处同名参数结构不同,编写悬浮配置时不要混用。

这里要按照 HdsTabs 自身宽度选择档位,设备型号不能直接代替档位判断。当手机全屏时,HdsTabs 往往处在小窗口档;平板分屏以后,组件宽度仍可能较小;桌面窗口宽度逐渐增加时,才可能进入中档或大档。Demo 中的 HdsTabs 占满窗口,窗口变化会同步改变组件尺寸;局部布局里的 HdsTabs 则要按照组件自身尺寸测试。手机、平板和 2in1 不能直接映射到小、中、大三档,分屏和自由窗口一出现,这种映射就会失效。

三档的分界由 HdsTabs 宽度和高宽比共同决定。smallWidthmediumWidthlargeWidth 都是 Length,Demo 采用数字形式设置,组件按照 vp 处理;百分比暂不支持。

字段 HdsTabs 宽度条件 主要观察点
smallWidth 小于 440vp 窗口宽度较小时三个入口是否拥挤,左右是否还保留适当空间
mediumWidth 440vp~600vp;或 600vp~840vp 且 HdsTabs 高宽比小于 0.8 窗口宽度开始增加以后,入口间距是否仍然连贯
largeWidth 大于 840vp;或 600vp~840vp 且 HdsTabs 高宽比大于 0.8 窗口宽度较大时页签横向尺寸是否过大,导航是否还像一个整体

600vp~840vp 这一段还会结合高宽比,只记录窗口宽度数字并不充分。相同宽度下,纵向空间较大的窗口与横向空间更充足的窗口可能使用不同档位。大家测试时可以同时记录窗口宽度和形态,避免尺寸变化以后误以为状态没有刷新。

不设置 barWidth 时,HdsTabs 自己也有默认尺寸规则。页签数量不超过三个且组件宽度小于 600vp 时,单个页签默认 76vp;组件宽度达到 600vp 后,单个页签默认 80vp。页签达到四个及以上时,页签栏最大宽度不超过 328vp。默认值适合快速起步,自定义宽度则适合对品牌视觉、入口文字长度或页面留白已有明确要求的项目。

页签数量会改变对宽度的判断。三个字符较少的标题排列在 240vp 中,平均每个入口约有 80vp;更换为字符较多的标题以后,文字可能已经贴近相邻入口。三个入口如果分布在四百多 vp 的页签栏中,图标之间会留下大段空白,整体感也会减弱。这属于移动端导航的常见设计经验,HdsTabs 本身没有强制限制这一选择。

Demo 把宽度设置为三组 Profile,每次点击会同时修改三个字段:

Profile smallWidth mediumWidth largeWidth 实验意图
紧凑 216vp 280vp 344vp 检查文字是否贴近相邻入口,左右是否还保留空间
默认 240vp 320vp 400vp 保留适中的入口距离,作为本页初始状态
宽松 280vp 368vp 456vp 增大三档差异,便于观察窗口使用的是哪一档

这些数值只是参数实验的对照,不代表项目推荐值,也不代表某类设备必须采用某个宽度。实验页面会同时显示另外两档配置,方便确认状态是否更新;要确认窗口使用的是哪一档,仍需在 HarmonyOS 7 模拟器中改变窗口宽度和形态。没有运行结果以前,不能把 mediumWidthlargeWidth 的预期变化表述为已经验证的现象。

调整时要结合一级入口数量和标题确定小窗口值。手机状态稳定后,将窗口调整到中档与大档,观察页签横向尺寸是否突然变化、三个入口之间是否出现过长空白。底栏边缘与屏幕的距离、图标和文字是否过于靠近,也要一起记录。每次修改一档就截取一张同位置图片,比凭印象反复点击按钮更容易比较。

如果现有工程允许横竖屏或自由窗口,还要专门检查临界区附近。窗口经过分界值时,页签不能一会儿贴边,一会儿又明显收窄。可以故意增大三档差值,确认组件使用了哪一档;确认以后,将差值缩小到项目最终采用的数值。三个宽度值同时随意修改,会增加排查工作量。

二、底部间距需要和页面底部一起观察

页签与底边距离过小时,外缘可能贴到导航指示器附近;位置过高,又会遮挡更多滚动内容。barBottomMargin 决定悬浮页签与 HdsTabs 底部之间的距离,类型是 Length,默认值为 0vp。这个参数没有统一的最小值或最大值,Demo 使用的 16、28、40 都按照 vp 处理,只是为了放大差异,方便比较。

这个参数减小时,页签会更靠近页面底部,也更容易和系统导航指示器、圆角屏幕边缘或底部手势区域发生空间冲突。数值增大以后,页签向上抬升,底边会保留更多空间,同时遮挡更多内容,滚动卡片从底栏后方经过的路径也会变长。它只改变悬浮栏的位置,安全区域仍然要由页面布局处理。

底部系统区域会受设备、窗口模式和页面是否扩展安全区域影响。同一个 margin 在两台设备上呈现不同距离很常见。测试时要同时检查页签是否贴住导航指示器、最下方入口能否稳定点击,以及悬浮栏与屏幕底边之间是否留下过大的空白。遇到沉浸式窗口或特殊安全区域配置,还要把页面布局带到真机上一起检查,barBottomMargin 不能替代安全区域适配。

barOverlap=true 后,内容可以延伸到 TabBar 后方,barBottomMargin 和页面底部可操作空间需要一起检查。把数值调整到 40vp 后,页签位置会向上移动,但不会自动为列表最后一项腾出可点击空间。如果最后一张卡片停在页签下面,用户只能浏览局部内容又无法点击,就要检查内容区的底部留白和滚动终点。

Demo 在滚动 Column 底部保留 170vp,目的是让最后一张实验卡片能够完整滚出悬浮页签覆盖区。这个 170vp 不属于 barBottomMargin + TabBar 高度 的固定公式。正式页面还要根据最后一个元素决定空间:纯背景可以进入页签后方,说明文字最好能够完整读完,按钮、输入框和列表操作项则需要滚动到页签上方后停住。

比较三个档位时,保持滚动位置不变。16vp 主要用于暴露页签是否贴近底部系统区域;调整为 28vp 后,记录页签外缘和滚动内容之间的距离;调整为 40vp 后,确认页签上移会遮挡多少正文。滚动位置变化会干扰判断,确定数值以后还要切换三个 TabContent,确认不同页面的底部留白保持一致。

页面底部如果还有“提交”“继续”一类固定操作,需要同时确认按钮与页签不会互相覆盖、滚动内容能够越过两者。遇到这种页面,往往需要重新安排底部结构;继续增大 barBottomMargin 只会让悬浮栏占据更多内容空间。参数可以调整间距,按钮能否完整显示和点击仍由页面布局负责。

三、gradientMask 用于降低内容对导航图标的干扰

彩色卡片或文字滚到页签后面时,背景细节可能贴近导航图标,图标和文字的辨识度会降低。gradientMask 通过一段渐变把内容和悬浮栏分开,它接收 HdsTabsBackgroundStylemaskColorResourceColor,决定遮罩颜色和透明度;maskHeight 是 number,决定遮罩覆盖高度。遮罩高度不能为 0,因此 Demo 关闭遮罩时直接省略 gradientMask,不会把 maskHeight 设置成 0。

遮罩存在时,内容进入 TabBar 后方会经过一段颜色过渡,导航图标不会直接叠在彩色卡片、图片纹理或密集文字上。遮罩强度过低,文字仍会透到图标附近;不透明度过高,页面底部会形成一块明显色带,正文还没靠近页签就开始消失。

maskColor 需要同时观察色值与透明度。#66F1F3F5 里的 66 带来透明度,后面的颜色与 Demo 的浅灰背景接近,彩色卡片靠近页签时会逐渐回到页面底色。直接迁移到深色页面或大面积图片上,底部可能出现一条发白的色带。深色模式要单独检查,不能直接沿用浅色遮罩。

maskHeight 控制的是过渡范围。数值较小时,变化集中在页签附近,彩色内容可能接近页签时才开始淡化;数值较大时,过渡更早出现,正文可读区域会减少。Demo 提供 72、92、112 三档,初始选择 92vp。切换时应将滚动内容停在同一位置,观察卡片标题、正文和背景色分别从哪里开始减弱。

gradientMask 的默认值目前有两种写法。HdsTabsFloatingStyle.gradientMask 字段说明采用浅色 #CCF1F3F5、深色 #99000000,高度为页签栏默认高度增加 16vp;同页的 HdsTabsBackgroundStyle 说明采用 #CCFFFFFF,高度为包含底部 TabBar 的组件高度增加 32vp。本地 API 26 SDK 能确认字段类型,maskHeight 注释延续后一种高度说明,却没有给出一组独立且完整的 gradientMask 默认值。开发指南示例显式设置 #66F1F3F5 和 92vp,这些信息能确认参数写法,还不足以确定组件最终采用哪组默认值。Demo 因此显式设置 maskColormaskHeight,不依赖尚未统一的默认配置。

是否启用遮罩,还要回到内容本身。简单纯色背景没有必要为了使用 API 强行增加遮罩;页签轮廓和文字已经能够辨认时,省略 gradientMask 还可以避免底部多出一段色带。信息流图片、彩色卡片和高密度列表经过悬浮栏后方时,更容易干扰导航图标,因此更值得测试;静态空白页可能完全不需要。

有图片的页面不能仅凭一张浅色图片判断。至少选择浅色图片、深色图片和细节密集的图片依次检查。普通列表则要关注文字基线经过遮罩时是否还能辨认,以及最后一个可操作项能否滚出覆盖区。遮罩只负责视觉过渡,内容底部留白仍由页面布局处理;两者同时调整,常会出现颜色已经合适、按钮却仍被页签遮挡的情况。

调整时关闭遮罩,用于分辨问题来自内容覆盖还是遮罩本身。确定问题来源后,固定一种接近页面背景的颜色并比较三档高度,最后微调透明度。每次只改变一个变量,截图才能说明是哪项设置带来了变化。页面包含浅色与深色两套主题时,两套颜色各检查一遍,避免让一份色值勉强覆盖全部场景。

完整 Demo 的开关采用两份 HdsTabsFloatingStyle 返回值:开启时包含 gradientMask,关闭时保留 barWidthbarBottomMargin,仅省略遮罩对象。这样的实验页能让宽度、底部位置和遮罩彼此独立,切换任何一项都不会连带修改另外两项。页面发生变化时,也更容易知道变化来自哪项设置。

总结

项目里可以确定一级入口数量和标题,依次调整三个窗口宽度档位,观察页签横向尺寸何时增加、图标之间是否突然出现大段空白。宽度稳定以后,调整页签离底边的距离,并把列表滚到底,确认最后一个按钮或列表项能够完整露出。

遮罩安排在最后。只有内容经过页签后方、背景细节开始干扰导航图标时才需要开启;固定滚动位置以后,比较颜色、高度和透明度。三个变量由此分开:底部间距只改变页签位置,遮罩只处理视觉过渡,它们都不会自动补足内容区留白。

我目前手里还没有可以测试 HarmonyOS 7 的真机,所以现在只能先在模拟器里验证,最终效果还是要以真机实际运行结果为准。

完整代码

/**
 * HarmonyOS 7 悬浮页签深度实战 03
 *
 */

import {
  HdsTabs,
  HdsTabsController,
  HdsTabsFloatingStyle
} from '@kit.UIDesignKit';

@Entry
@Component
struct Main {
  private tabsController: HdsTabsController = new HdsTabsController();

  // 0:紧凑,1:默认,2:宽松。
  @State private widthProfile: number = 1;
  @State private bottomMargin: number = 28;
  @State private maskEnabled: boolean = true;
  @State private maskHeight: number = 92;

  private getSmallWidth(): number {
    if (this.widthProfile === 0) {
      return 216;
    }
    if (this.widthProfile === 2) {
      return 280;
    }
    return 240;
  }

  private getMediumWidth(): number {
    if (this.widthProfile === 0) {
      return 280;
    }
    if (this.widthProfile === 2) {
      return 368;
    }
    return 320;
  }

  private getLargeWidth(): number {
    if (this.widthProfile === 0) {
      return 344;
    }
    if (this.widthProfile === 2) {
      return 456;
    }
    return 400;
  }

  private getFloatingStyle(): HdsTabsFloatingStyle {
    if (this.maskEnabled) {
      return {
        barWidth: {
          smallWidth: this.getSmallWidth(),
          mediumWidth: this.getMediumWidth(),
          largeWidth: this.getLargeWidth()
        },
        barBottomMargin: this.bottomMargin,
        gradientMask: {
          maskColor: '#66F1F3F5',
          maskHeight: this.maskHeight
        }
      };
    }

    return {
      barWidth: {
        smallWidth: this.getSmallWidth(),
        mediumWidth: this.getMediumWidth(),
        largeWidth: this.getLargeWidth()
      },
      barBottomMargin: this.bottomMargin
    };
  }

  @Builder
  private sectionHeader(title: string, description: string) {
    Column({ space: 4 }) {
      Text(title)
        .fontSize(14)
        .fontWeight(FontWeight.Medium)
        .fontColor('#18213A')
        .width('100%')

      Text(description)
        .fontSize(12)
        .fontColor('#68708A')
        .lineHeight(18)
        .width('100%')
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
  }

  @Builder
  private widthControls() {
    Column({ space: 10 }) {
      this.sectionHeader(
        'Width Profile',
        '每个档位都会同时改写 small / medium / large 三个宽度。'
      )

      Row({ space: 8 }) {
        Button('紧凑')
          .layoutWeight(1)
          .height(38)
          .fontSize(13)
          .fontColor(this.widthProfile === 0 ? Color.White : '#5065E8')
          .backgroundColor(this.widthProfile === 0 ? '#5065E8' : '#EEF1FF')
          .onClick(() => {
            this.widthProfile = 0;
          })

        Button('默认')
          .layoutWeight(1)
          .height(38)
          .fontSize(13)
          .fontColor(this.widthProfile === 1 ? Color.White : '#5065E8')
          .backgroundColor(this.widthProfile === 1 ? '#5065E8' : '#EEF1FF')
          .onClick(() => {
            this.widthProfile = 1;
          })

        Button('宽松')
          .layoutWeight(1)
          .height(38)
          .fontSize(13)
          .fontColor(this.widthProfile === 2 ? Color.White : '#5065E8')
          .backgroundColor(this.widthProfile === 2 ? '#5065E8' : '#EEF1FF')
          .onClick(() => {
            this.widthProfile = 2;
          })
      }
      .width('100%')

      Text(
        `当前三档:${this.getSmallWidth()} / ` +
        `${this.getMediumWidth()} / ${this.getLargeWidth()} vp`
      )
        .fontSize(12)
        .fontColor('#5065E8')
        .width('100%')
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
  }

  @Builder
  private bottomMarginControls() {
    Column({ space: 10 }) {
      this.sectionHeader(
        'Bottom Margin',
        '16 / 28 / 40 vp 是本页实验值,用于观察页签离底部的距离。'
      )

      Row({ space: 8 }) {
        Button('16 vp')
          .layoutWeight(1)
          .height(38)
          .fontSize(13)
          .fontColor(this.bottomMargin === 16 ? Color.White : '#5065E8')
          .backgroundColor(this.bottomMargin === 16 ? '#5065E8' : '#EEF1FF')
          .onClick(() => {
            this.bottomMargin = 16;
          })

        Button('28 vp')
          .layoutWeight(1)
          .height(38)
          .fontSize(13)
          .fontColor(this.bottomMargin === 28 ? Color.White : '#5065E8')
          .backgroundColor(this.bottomMargin === 28 ? '#5065E8' : '#EEF1FF')
          .onClick(() => {
            this.bottomMargin = 28;
          })

        Button('40 vp')
          .layoutWeight(1)
          .height(38)
          .fontSize(13)
          .fontColor(this.bottomMargin === 40 ? Color.White : '#5065E8')
          .backgroundColor(this.bottomMargin === 40 ? '#5065E8' : '#EEF1FF')
          .onClick(() => {
            this.bottomMargin = 40;
          })
      }
      .width('100%')
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
  }

  @Builder
  private maskControls() {
    Column({ space: 10 }) {
      this.sectionHeader(
        'Gradient Mask',
        '先开关遮罩,再比较 72 / 92 / 112 vp 三种过渡高度。'
      )

      Row({ space: 8 }) {
        Button('关闭')
          .layoutWeight(1)
          .height(38)
          .fontSize(13)
          .fontColor(this.maskEnabled ? '#5065E8' : Color.White)
          .backgroundColor(this.maskEnabled ? '#EEF1FF' : '#5065E8')
          .onClick(() => {
            this.maskEnabled = false;
          })

        Button('开启')
          .layoutWeight(1)
          .height(38)
          .fontSize(13)
          .fontColor(this.maskEnabled ? Color.White : '#5065E8')
          .backgroundColor(this.maskEnabled ? '#5065E8' : '#EEF1FF')
          .onClick(() => {
            this.maskEnabled = true;
          })
      }
      .width('100%')

      Row({ space: 8 }) {
        Button('72 vp')
          .layoutWeight(1)
          .height(36)
          .fontSize(12)
          .fontColor(this.maskHeight === 72 ? Color.White : '#596078')
          .backgroundColor(this.maskHeight === 72 ? '#7483E8' : '#F2F3F7')
          .onClick(() => {
            this.maskHeight = 72;
          })

        Button('92 vp')
          .layoutWeight(1)
          .height(36)
          .fontSize(12)
          .fontColor(this.maskHeight === 92 ? Color.White : '#596078')
          .backgroundColor(this.maskHeight === 92 ? '#7483E8' : '#F2F3F7')
          .onClick(() => {
            this.maskHeight = 92;
          })

        Button('112 vp')
          .layoutWeight(1)
          .height(36)
          .fontSize(12)
          .fontColor(this.maskHeight === 112 ? Color.White : '#596078')
          .backgroundColor(this.maskHeight === 112 ? '#7483E8' : '#F2F3F7')
          .onClick(() => {
            this.maskHeight = 112;
          })
      }
      .width('100%')
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
  }

  @Builder
  private parameterPanel() {
    Column({ space: 18 }) {
      this.widthControls()
      Divider().color('#E8EAF0')
      this.bottomMarginControls()
      Divider().color('#E8EAF0')
      this.maskControls()
    }
    .width('100%')
    .padding(16)
    .backgroundColor(Color.White)
    .borderRadius(20)
    .alignItems(HorizontalAlign.Start)
  }

  @Builder
  private infoCard(
    index: string,
    title: string,
    description: string,
    background: string
  ) {
    Column({ space: 8 }) {
      Text(index)
        .fontSize(11)
        .fontWeight(FontWeight.Medium)
        .fontColor('#5065E8')
        .width('100%')

      Text(title)
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#17203A')
        .width('100%')

      Text(description)
        .fontSize(13)
        .fontColor('#596078')
        .lineHeight(20)
        .width('100%')
    }
    .width('100%')
    .height(132)
    .padding(16)
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Start)
    .backgroundColor(background)
    .borderRadius(20)
  }

  @Builder
  private experimentPage(title: string, description: string) {
    Scroll() {
      Column({ space: 12 }) {
        Column({ space: 6 }) {
          Text(title)
            .fontSize(27)
            .fontWeight(FontWeight.Bold)
            .fontColor('#11182C')
            .width('100%')

          Text(description)
            .fontSize(14)
            .fontColor('#68708A')
            .lineHeight(21)
            .width('100%')
        }
        .width('100%')
        .alignItems(HorizontalAlign.Start)

        this.parameterPanel()

        this.infoCard(
          '01 · BLUE',
          '窗口宽度实验',
          '调整窗口或旋转设备,再切换 Width Profile,观察当前命中的宽度档位。',
          '#E9F2FF'
        )

        this.infoCard(
          '02 · ORANGE',
          '底部间距实验',
          '依次选择 16、28、40 vp,观察页签与页面底边之间的距离。',
          '#FFF0DF'
        )

        this.infoCard(
          '03 · GREEN',
          '遮罩开关实验',
          '滚动到彩色卡片经过页签后方的位置,再比较遮罩开启与关闭。',
          '#E9F8F1'
        )

        this.infoCard(
          '04 · PURPLE',
          '遮罩高度实验',
          '遮罩开启时切换三档高度,观察内容淡出的起点是否过早。',
          '#F3ECFF'
        )

        this.infoCard(
          '05 · RED',
          '底部内容检查',
          '继续上滑,确认最后一张卡片能够完整滚出悬浮页签覆盖区域。',
          '#FFECEF'
        )
      }
      .width('100%')
      .padding({
        left: 20,
        right: 20,
        top: 18,
        bottom: 170
      })
    }
    .width('100%')
    .height('100%')
    .scrollBar(BarState.Off)
    .backgroundColor('#F4F6FB')
  }

  build() {
    Column() {
      HdsTabs({
        controller: this.tabsController
      }) {
        TabContent() {
          this.experimentPage(
            '悬浮页签参数实验',
            '首页:集中观察 barWidth、barBottomMargin 和 gradientMask。'
          )
        }
        .tabBar(
          new BottomTabBarStyle(
            $r('sys.media.ohos_ic_public_clock'),
            '首页'
          )
        )

        TabContent() {
          this.experimentPage(
            '任务页',
            '参数状态跨页签保留,便于检查三个页面的一致性。'
          )
        }
        .tabBar(
          new BottomTabBarStyle(
            $r('sys.media.ohos_ic_public_clock'),
            '任务'
          )
        )

        TabContent() {
          this.experimentPage(
            '我的页面',
            '继续滚动彩色卡片,观察内容进入遮罩区域后的变化。'
          )
        }
        .tabBar(
          new BottomTabBarStyle(
            $r('sys.media.ohos_ic_public_clock'),
            '我的'
          )
        )
      }
      .barPosition(BarPosition.End)
      .vertical(false)
      .barOverlap(true)
      .barFloatingStyle(this.getFloatingStyle())
      .width('100%')
      .height('100%')
    }
    .width('100%')
    .height('100%')
  }
}
Logo

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

更多推荐