【HarmonyOS 7 悬浮页签深度实战】03 barFloatingStyle 的宽度、底部间距与遮罩如何配置
前言
悬浮页签能够正常显示以后,宽度、底部距离和渐变遮罩就要开始调整了。宽度不合适,三个入口会显得拥挤或相隔太远;底部距离不合适,页签可能贴近系统导航区域,也可能与底边相距过大;遮罩的不透明度过高,正文还没靠近页签就开始变淡。
手机端窗口宽度较小时,三个入口排列在一条两百多 vp 的页签栏中,图标和文字通常还能保持紧凑。窗口宽度增加以后,如果页签栏也跟着一直伸展,三个入口之间会出现大段空白,视线和手指的移动距离也会增加。底部距离也没有一个适合所有页面的数字:同样的 28vp,在模拟器、带导航指示器的手机和可调整窗口中,有时会让页签贴近导航指示器,有时又会让它与底边相距较大。
barOverlap 开启后,TabContent 会继续延伸到 TabBar 后方。彩色卡片、图片和文字从悬浮栏下面经过时,导航图标可能被背景细节干扰;遮罩的不透明度过高或覆盖范围过大,正文又会提前淡掉。调试时一次只修改一项,三项数值确定以后,还要结合窗口宽度、滚动位置和主题检查。
barFloatingStyle 接收可选的 HdsTabsFloatingStyle,这组悬浮样式从 6.1.0(23) 开始提供。它要求页签位于底部 barPosition=BarPosition.End、保持横向排列 vertical=false,同时开启内容覆盖 barOverlap=true;页签内容采用 BottomTabBarStyle 或 CustomBuilder。缺少任何一项,继续调整宽度、间距或遮罩也无法解决结构问题。

一、宽度跟着窗口变化
三个入口在手机宽度下排列正常,窗口宽度增加后,悬浮栏可能同步伸展为横向尺寸过大的页签栏,图标之间留下大段空白。barFloatingStyle 里的 barWidth 用于限制悬浮 TabBar 自身的宽度,类型是 HdsBarWidthRangeOptions,其中包含 smallWidth、mediumWidth 和 largeWidth 三档。Demo 在一个对象里同时设置三档值,HdsTabs 根据组件所处的宽度区间选择一档。HdsTabs 自身还有接收 Length 的普通 barWidth 属性,两处同名参数结构不同,编写悬浮配置时不要混用。
这里要按照 HdsTabs 自身宽度选择档位,设备型号不能直接代替档位判断。当手机全屏时,HdsTabs 往往处在小窗口档;平板分屏以后,组件宽度仍可能较小;桌面窗口宽度逐渐增加时,才可能进入中档或大档。Demo 中的 HdsTabs 占满窗口,窗口变化会同步改变组件尺寸;局部布局里的 HdsTabs 则要按照组件自身尺寸测试。手机、平板和 2in1 不能直接映射到小、中、大三档,分屏和自由窗口一出现,这种映射就会失效。
三档的分界由 HdsTabs 宽度和高宽比共同决定。smallWidth、mediumWidth、largeWidth 都是 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 模拟器中改变窗口宽度和形态。没有运行结果以前,不能把 mediumWidth 与 largeWidth 的预期变化表述为已经验证的现象。
调整时要结合一级入口数量和标题确定小窗口值。手机状态稳定后,将窗口调整到中档与大档,观察页签横向尺寸是否突然变化、三个入口之间是否出现过长空白。底栏边缘与屏幕的距离、图标和文字是否过于靠近,也要一起记录。每次修改一档就截取一张同位置图片,比凭印象反复点击按钮更容易比较。
如果现有工程允许横竖屏或自由窗口,还要专门检查临界区附近。窗口经过分界值时,页签不能一会儿贴边,一会儿又明显收窄。可以故意增大三档差值,确认组件使用了哪一档;确认以后,将差值缩小到项目最终采用的数值。三个宽度值同时随意修改,会增加排查工作量。

二、底部间距需要和页面底部一起观察
页签与底边距离过小时,外缘可能贴到导航指示器附近;位置过高,又会遮挡更多滚动内容。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 通过一段渐变把内容和悬浮栏分开,它接收 HdsTabsBackgroundStyle:maskColor 是 ResourceColor,决定遮罩颜色和透明度;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 因此显式设置 maskColor 与 maskHeight,不依赖尚未统一的默认配置。
是否启用遮罩,还要回到内容本身。简单纯色背景没有必要为了使用 API 强行增加遮罩;页签轮廓和文字已经能够辨认时,省略 gradientMask 还可以避免底部多出一段色带。信息流图片、彩色卡片和高密度列表经过悬浮栏后方时,更容易干扰导航图标,因此更值得测试;静态空白页可能完全不需要。
有图片的页面不能仅凭一张浅色图片判断。至少选择浅色图片、深色图片和细节密集的图片依次检查。普通列表则要关注文字基线经过遮罩时是否还能辨认,以及最后一个可操作项能否滚出覆盖区。遮罩只负责视觉过渡,内容底部留白仍由页面布局处理;两者同时调整,常会出现颜色已经合适、按钮却仍被页签遮挡的情况。
调整时关闭遮罩,用于分辨问题来自内容覆盖还是遮罩本身。确定问题来源后,固定一种接近页面背景的颜色并比较三档高度,最后微调透明度。每次只改变一个变量,截图才能说明是哪项设置带来了变化。页面包含浅色与深色两套主题时,两套颜色各检查一遍,避免让一份色值勉强覆盖全部场景。
完整 Demo 的开关采用两份 HdsTabsFloatingStyle 返回值:开启时包含 gradientMask,关闭时保留 barWidth 和 barBottomMargin,仅省略遮罩对象。这样的实验页能让宽度、底部位置和遮罩彼此独立,切换任何一项都不会连带修改另外两项。页面发生变化时,也更容易知道变化来自哪项设置。
总结
项目里可以确定一级入口数量和标题,依次调整三个窗口宽度档位,观察页签横向尺寸何时增加、图标之间是否突然出现大段空白。宽度稳定以后,调整页签离底边的距离,并把列表滚到底,确认最后一个按钮或列表项能够完整露出。
遮罩安排在最后。只有内容经过页签后方、背景细节开始干扰导航图标时才需要开启;固定滚动位置以后,比较颜色、高度和透明度。三个变量由此分开:底部间距只改变页签位置,遮罩只处理视觉过渡,它们都不会自动补足内容区留白。
我目前手里还没有可以测试 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%')
}
}
更多推荐



所有评论(0)