小雨同学 2026-09-18 12:32:32 发布前言
悬浮页签能够正常显示以后,宽度、底部距离和渐变遮罩就要开始调整了。宽度不合适,三个入口会显得拥挤或相隔太远;底部距离不合适,页签可能贴近系统导航区域,也可能与底边相距过大;遮罩的不透明度过高,正文还没靠近页签就开始变淡。
手机端窗口宽度较小时,三个入口排列在一条两百多 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@Componentstruct 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%') }}暂无评论数据
发布
相关推荐
203
0
52
0
174
0
小雨同学
产品总监、独立开发者社群主理人、资深全栈工程师,HarmonyOS应用开发者高级认证,PMP认证,CSDN博客专家,鸿蒙极客,Trae Fellow,阿里云社区专家博主、51CTO 博客专家、OpenTiny 优秀布道师、科大讯飞荣誉讲师。
帖子
提问
粉丝
【HarmonyOS 7 悬浮页签深度实战】04 HdsTabsController 如何协调页签切换与显隐
2026-09-19 13:17:00 发布【HarmonyOS 7 悬浮页签深度实战】03 barFloatingStyle 的宽度、底部间距与遮罩如何配置
2026-09-18 12:32:32 发布


京公网安备:11010502051901号