鸿蒙NEXT ArkUI进阶:用CustomBuilder打造高定制化品牌页签栏
几乎每个移动端应用的底部导航栏都会面临同一个问题:系统提供的默认样式虽然稳定、规范,但往往无法满足品牌化定制的需求。比如社交媒体应用需要在"消息"页签上显示未读角标,电商应用需要在"购物车"页签上显示商品数量,内容平台希望在选中态有品牌色的丝滑过渡动画。

一、引言
几乎每个移动端应用的底部导航栏都会面临同一个问题:系统提供的默认样式虽然稳定、规范,但往往无法满足品牌化定制的需求。比如社交媒体应用需要在"消息"页签上显示未读角标,电商应用需要在"购物车"页签上显示商品数量,内容平台希望在选中态有品牌色的丝滑过渡动画。
HarmonyOS NEXT 的 HdsTabs 组件为这类需求提供了优雅的解决方案:除了内置的 BottomTabBarStyle 标准样式,它还支持通过 CustomBuilder 完全自定义页签栏的 UI。这意味着开发者可以使用任何 ArkUI 组件来构建页签的外观——图标、文字、角标、动画甚至自定义布局,一切由你掌控。
本文将完整讲解 CustomBuilder 自定义页签栏的原理和实现,包含角标系统、图标尺寸适配、选中态动态样式等实战技巧。
二、两种页签栏定义方式对比
在深入 CustomBuilder 之前,先认识一下 HdsTabs 的两种页签栏定义方式。
2.1 BottomTabBarStyle:标准样式
BottomTabBarStyle 是系统内置的标准页签样式,构造函数只需传入图标资源和文字标签:
TabContent() {
// 页面内容...
}
.tabBar(new BottomTabBarStyle($r('sys.media.ohos_ic_public_clock'), '首页'))
这种方式的优点是零配置、开箱即用,系统会自动处理图标大小、文字大小、选中态颜色等视觉细节。缺点也很明显:你无法添加角标、无法自定义布局、无法实现品牌化视觉。
2.2 CustomBuilder:完全自定义
CustomBuilder 方式则完全不同——页签栏的内容由一个 @Builder 函数全权负责。你可以在 @Builder 函数中放置任何 ArkUI 组件:
// 定义一个 @Builder 函数
@Builder
myCustomTabBar() {
Column() {
Image($r('sys.media.ohos_ic_public_clock'))
.width(24)
.height(24)
.fillColor('#007AFF')
Text('首页')
.fontSize(10)
.fontColor('#007AFF')
}
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
// 在 TabContent 上使用
TabContent() {
// 页面内容...
}
.tabBar(this.myCustomTabBar)
两者的核心差异:
| 特性 | BottomTabBarStyle | CustomBuilder |
|---|---|---|
| 自定义图标 | 仅限系统图标 | 任意 Image 组件 |
| 角标支持 | 不支持 | 完全支持 |
| 选中态动画 | 固定样式 | 自定义动画 |
| 品牌色定制 | 有限 | 完全掌控 |
| 开发成本 | 极低 | 中等 |
三、使用 @Builder 构建自定义页签
3.1 基础结构
CustomBuilder 自定义页签的核心是用 @Builder 函数构建一个 UI 描述块。系统会将该 @Builder 的渲染结果作为页签的视觉呈现。一个典型的自定义页签结构如下:
@Builder
CustomTabContent(index: number) {
Column() {
// 图标层(支持 Stack 叠加角标)
Stack() {
Image(this.tabIcons[index])
.width(22)
.height(22)
.fillColor(index === this.currentTabIndex ? '#007AFF' : '#8E8E93')
// 角标层(条件渲染)
if (this.showBadge && this.badgeCounts[index] > 0) {
Text(this.badgeCounts[index] > 99 ? '99+' : String(this.badgeCounts[index]))
.fontSize(9)
.fontColor('#FFFFFF')
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
.backgroundColor('#FF3B30')
.borderRadius(8)
.position({ x: 14, y: -8 })
}
}
// 文字标签层
Text(this.tabLabels[index])
.fontSize(10)
.fontColor(index === this.currentTabIndex ? '#007AFF' : '#8E8E93')
.fontWeight(index === this.currentTabIndex ? FontWeight.Medium : FontWeight.Normal)
.margin({ top: 2 })
}
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.onClick(() => {
this.currentTabIndex = index;
this.controller.changeIndex(index);
})
}
这个构建器使用了三层结构:
- Stack 容器承载图标和角标的叠加布局
- 条件渲染的角标在特定条件下出现在图标右上角
- 文字标签独立放置在图标下方
3.2 数据准备
在实现之前,先准备好页签相关的数据:
@Entry
@Component
struct CustomTabBarDemo {
private controller: HdsTabsController = new HdsTabsController();
@State currentTabIndex: number = 0;
@State showBadge: boolean = true;
@State useLargeIcon: boolean = false;
private tabIcons: Resource[] = [
$r('sys.media.ohos_ic_public_clock'),
$r('sys.media.ohos_ic_public_phone'),
$r('sys.media.ohos_ic_public_clock'),
$r('sys.media.ohos_ic_public_phone')
];
private tabLabels: string[] = ['首页', '热门', '消息', '我的'];
private badgeCounts: number[] = [0, 0, 5, 99];
}
3.3 绑定到 TabContent
在 HdsTabs 容器中,每个 TabContent 通过 .tabBar() 绑定对应的 @Builder 函数。由于每个页签需要传入不同的索引参数,需要用包装函数来指定参数值:
// 为每个页签创建指定索引的包装 Builder
@Builder TabBarBuilder0() { this.CustomTabContent(0) }
@Builder TabBarBuilder1() { this.CustomTabContent(1) }
@Builder TabBarBuilder2() { this.CustomTabContent(2) }
@Builder TabBarBuilder3() { this.CustomTabContent(3) }
// 在 HdsTabs 中使用
HdsTabs({ controller: this.controller }) {
TabContent() { /* 首页内容 */ }
.tabBar(this.TabBarBuilder0)
TabContent() { /* 热门内容 */ }
.tabBar(this.TabBarBuilder1)
TabContent() { /* 消息内容 */ }
.tabBar(this.TabBarBuilder2)
TabContent() { /* 我的内容 */ }
.tabBar(this.TabBarBuilder3)
}
这种包装模式是必需的,因为 .tabBar() 方法接受的是无参的 CustomBuilder 类型,而我们的 CustomTabContent 需要参数来区分不同页签。
四、角标系统完整实现
角标是自定义页签栏中最常见的需求。下面详细拆解角标的实现原理。
4.1 角标的定位机制
角标利用了 Stack 组件的叠加特性。在 Stack 中,子组件按照声明顺序从底到上叠加。通过 .position() API 设置精确的 x、y 偏移,将角标定位到图标的右上角:
Stack() {
// 底层:页签图标
Image(this.tabIcons[index])
.width(22)
.height(22)
.fillColor(iconColor)
// 顶层:角标(条件渲染)
if (this.showBadge && this.badgeCounts[index] > 0) {
Text(this.badgeCounts[index] > 99 ? '99+' : String(this.badgeCounts[index]))
.fontSize(9)
.fontColor('#FFFFFF')
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
.backgroundColor('#FF3B30')
.borderRadius(8)
.position({ x: 14, y: -8 }) // 定位到右上角
}
}
.position({ x: 14, y: -8 }) 的含义:
x: 14:从图标左边缘向右偏移 14vp,对于 22vp 的图标,这个位置大约在图标右侧y: -8:向上偏移 8vp,让角标"冒"出图标上方
4.2 角标数值格式化
角标数值需要考虑两个特殊情况:
// 角标显示文本
Text(this.badgeCounts[index] > 99 ? '99+' : String(this.badgeCounts[index]))
- 当数值为 0 时,通过外层 if 条件直接隐藏角标
- 当数值 > 99 时,显示 “99+” 而非实际数字,避免角标过宽撑破布局
- 正常范围(1-99)显示实际数字
4.3 三种角标样式
根据业务需求,可以实现不同的角标样式:
// 样式1:数字角标(适用于未读消息数)
if (count > 0) {
Text(count > 99 ? '99+' : String(count))
.fontSize(9)
.fontColor('#FFFFFF')
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
.backgroundColor('#FF3B30')
.borderRadius(8)
.position({ x: 14, y: -8 })
}
// 样式2:红点提示(适用于有更新但无需显示数量)
if (hasUpdate) {
Circle({ width: 8, height: 8 })
.fill('#FF3B30')
.position({ x: 16, y: -4 })
}
// 样式3:文字角标(适用于状态标识,如 "NEW")
if (isNewFeature) {
Text('NEW')
.fontSize(8)
.fontColor('#FFFFFF')
.padding({ left: 3, right: 3, top: 1, bottom: 1 })
.backgroundColor('#34C759')
.borderRadius(4)
.position({ x: 12, y: -10 })
}
五、选中态动态样式
选中态的视觉处理是页签栏交互体验的关键。CustomBuilder 模式下,你需要手动处理选中/未选中的样式变化。
5.1 图标颜色切换
通过对比当前页签索引与传入的索引,动态修改 fillColor:
Image(this.tabIcons[index])
.width(this.useLargeIcon ? 28 : 22)
.height(this.useLargeIcon ? 28 : 22)
.fillColor(index === this.currentTabIndex ? '#007AFF' : '#8E8E93')
选中时使用品牌蓝色 #007AFF,未选中时使用中性灰色 #8E8E93。
5.2 文字样式切换
同样基于索引匹配来动态调整文字的字体颜色和粗细:
Text(this.tabLabels[index])
.fontSize(10)
.fontColor(index === this.currentTabIndex ? '#007AFF' : '#8E8E93')
.fontWeight(index === this.currentTabIndex ? FontWeight.Medium : FontWeight.Normal)
.margin({ top: 2 })
选中态使用 Medium 字重和品牌色,未选中态使用 Normal 字重和灰色。这种微妙的重量感变化让用户清晰感知当前所在位置。
5.3 点击切换处理
在 Column 容器的 onClick 中同时更新状态和控制器:
.onClick(() => {
this.currentTabIndex = index; // 更新本地高亮状态
this.controller.changeIndex(index); // 通知 HdsTabs 切换页签
})
两步操作缺一不可:currentTabIndex 的更新让自定义页签 UI 的选中态生效,controller.changeIndex() 让 HdsTabs 容器切换到对应的 TabContent。
六、图标尺寸适配
不同的设计风格对图标大小有不同的要求。极简风格偏好小图标,内容型应用倾向大图标。通过一个状态变量即可灵活切换:
@State useLargeIcon: boolean = false;
// 在 CustomTabContent 中使用
Image(this.tabIcons[index])
.width(this.useLargeIcon ? 28 : 22)
.height(this.useLargeIcon ? 28 : 22)
标准尺寸(22vp)适合大多数常规场景,大图标(28vp)更适合内容驱动型应用——比如图片社区或视频平台,用户通过图标即可识别页签功能而不需要依赖文字标签。
两种尺寸的完整对比:
| 属性 | 标准图标 | 大图标 |
|---|---|---|
| 宽/高 | 22vp | 28vp |
| 适用场景 | 常规应用 | 视觉优先应用 |
| 文字依赖度 | 中等 | 低 |
| 布局压力 | 低 | 略高 |
七、完整 Demo:带控制面板的自定义页签栏
将上述所有知识点整合成一个可交互的 Demo:
import { HdsTabs, HdsTabsController, hdsMaterial } from '@kit.UIDesignKit';
@Entry
@Component
struct BrandedTabBarDemo {
private controller: HdsTabsController = new HdsTabsController();
@State showBadge: boolean = true;
@State currentTabIndex: number = 0;
@State useLargeIcon: boolean = false;
private tabIcons: Resource[] = [
$r('sys.media.ohos_ic_public_clock'),
$r('sys.media.ohos_ic_public_phone'),
$r('sys.media.ohos_ic_public_clock'),
$r('sys.media.ohos_ic_public_phone')
];
private tabLabels: string[] = ['首页', '热门', '消息', '我的'];
private badgeCounts: number[] = [0, 0, 5, 99];
build() {
Column() {
// 标题栏
Row() {
Text('自定义页签栏')
.fontSize(18)
.fontWeight(FontWeight.Medium)
.layoutWeight(1)
.textAlign(TextAlign.Center)
}
.width('100%')
.padding({ left: 16, right: 16, top: 40, bottom: 8 })
.backgroundColor('#FFFFFF')
// 控制面板
Column() {
// 角标开关
Row() {
Text('消息角标')
.fontSize(13).fontWeight(FontWeight.Medium).fontColor('#182431')
.width(90)
Row({ space: 8 }) {
Text('OFF')
.fontSize(11)
.fontColor(this.showBadge ? '#99182431' : '#FF3B30')
.fontWeight(this.showBadge ? FontWeight.Normal : FontWeight.Bold)
Text('|').fontSize(11).fontColor('#E5E5EA')
Text('ON')
.fontSize(11)
.fontColor(this.showBadge ? '#34C759' : '#99182431')
.fontWeight(this.showBadge ? FontWeight.Bold : FontWeight.Normal)
}
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor('#F2F2F7')
.borderRadius(16)
.onClick(() => { this.showBadge = !this.showBadge })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ left: 16, right: 16, top: 4, bottom: 4 })
// 图标大小切换
Row() {
Text('图标大小')
.fontSize(13).fontWeight(FontWeight.Medium).fontColor('#182431')
.width(90)
Row({ space: 8 }) {
Text('标准')
.fontSize(11)
.fontColor(this.useLargeIcon ? '#99182431' : '#007AFF')
.fontWeight(this.useLargeIcon ? FontWeight.Normal : FontWeight.Bold)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(this.useLargeIcon ? '#F2F2F7' : '#007AFF')
.borderRadius(14)
.onClick(() => { this.useLargeIcon = false })
Text('大图标')
.fontSize(11)
.fontColor(this.useLargeIcon ? '#007AFF' : '#99182431')
.fontWeight(this.useLargeIcon ? FontWeight.Bold : FontWeight.Normal)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(this.useLargeIcon ? '#007AFF14' : '#F2F2F7')
.borderRadius(14)
.onClick(() => { this.useLargeIcon = true })
}
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ left: 16, right: 16, top: 4, bottom: 4 })
}
.padding({ top: 8, bottom: 8 })
.backgroundColor('#FAFAFA')
Divider().color('#E5E5EA').height(0.5)
// HdsTabs — 使用 CustomBuilder 页签栏
HdsTabs({ controller: this.controller }) {
TabContent() {
Scroll() {
Column() {
// Hero 卡片
Column() {
Text('CustomBuilder 自定义页签')
.fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Text('完全自定义页签栏 UI 的每个像素')
.fontSize(12).fontColor('#99FFFFFF').margin({ top: 6 })
}
.width('100%')
.height(160)
.justifyContent(FlexAlign.Center)
.borderRadius(16)
.linearGradient({
direction: GradientDirection.Top,
colors: [['#007AFF', 0.0], ['#34C759', 1.0]]
})
.margin({ top: 16, left: 16, right: 16 })
// 文档卡片
Column({ space: 8 }) {
this.InfoCard('什么是 CustomBuilder?',
'HdsTabs 除支持 BottomTabBarStyle 系统样式外,还支持通过 CustomBuilder 自定义构建页签 UI。'
+ '开发者可通过 @Builder 函数完全控制页签的外观,包括图标、文字、角标、动画等。')
this.InfoCard('与 BottomTabBarStyle 对比',
'BottomTabBarStyle 提供标准化的图标+文字样式,开发简单但自定义能力有限。'
+ 'CustomBuilder 完全自定义,可添加角标、切换动画、动态颜色等高级效果,适合品牌化 UI 需求。')
this.InfoCard('角标实现原理',
'在 CustomBuilder 中使用 Stack 组件的 position 定位,可在图标右上角叠加角标视图。'
+ '角标可显示未读数、红点提示或状态标识,是社交/消息类应用的常见需求。')
}
.width('100%')
.padding({ left: 16, right: 16, top: 16, bottom: 100 })
}
}
.scrollBar(BarState.Off)
}
.tabBar(this.TabBarBuilder0)
TabContent() {
Scroll() {
Column({ space: 8 }) {
Text('热门').fontSize(20).fontWeight(FontWeight.Bold).padding({ top: 16, left: 16 })
ForEach(Array.from({ length: 10 }, (_: undefined, i: number) =>
'热门条目 ' + (i + 1)), (text: string) => {
Row() {
Text(text).fontSize(13).fontColor('#182431')
}
.width('100%').padding(14)
.backgroundColor('#FFFFFF').borderRadius(10)
})
Column().height(100)
}.padding({ left: 16, right: 16 })
}
}
.tabBar(this.TabBarBuilder1)
TabContent() {
Scroll() {
Column({ space: 8 }) {
Text('消息').fontSize(20).fontWeight(FontWeight.Bold).padding({ top: 16, left: 16 })
if (this.showBadge) {
Text('注意观察底部"消息"页签的角标')
.fontSize(11).fontColor('#FF9500')
.padding({ left: 16, right: 16, top: 6, bottom: 6 })
.backgroundColor('#FF950014').borderRadius(8).margin({ left: 16, right: 16 })
}
ForEach(['系统通知: 欢迎使用', '评论: 你的文章获得了赞',
'私信: 开发者邀请你加入群组', '系统: 应用更新至 v2.0'], (text: string) => {
Row() {
Text(text).fontSize(13).fontColor('#182431')
}
.width('100%').padding(14)
.backgroundColor('#FFFFFF').borderRadius(10)
})
Column().height(100)
}.padding({ left: 16, right: 16 })
}
}
.tabBar(this.TabBarBuilder2)
TabContent() {
Scroll() {
Column({ space: 8 }) {
Text('我的').fontSize(20).fontWeight(FontWeight.Bold).padding({ top: 16, left: 16 })
ForEach(['个人资料', '收藏夹', '历史记录', '下载管理', '设置'], (text: string) => {
Row() {
Text(text).fontSize(13).fontColor('#182431')
Image($r('sys.media.ohos_ic_public_arrow_right'))
.width(16).height(16).fillColor('#C7C7CC')
}
.width('100%').justifyContent(FlexAlign.SpaceBetween)
.padding(14)
.backgroundColor('#FFFFFF').borderRadius(10)
})
Column().height(100)
}.padding({ left: 16, right: 16 })
}
}
.tabBar(this.TabBarBuilder3)
}
.barOverlap(true)
.barPosition(BarPosition.End)
.vertical(false)
.barFloatingStyle({
barWidth: {
smallWidth: 200,
mediumWidth: 300,
largeWidth: 400
},
barBottomMargin: 28,
gradientMask: { maskColor: '#66F1F3F5', maskHeight: 92 },
systemMaterialEffect: {
materialType: hdsMaterial.MaterialType.IMMERSIVE,
materialLevel: hdsMaterial.MaterialLevel.ADAPTIVE
}
})
}
.width('100%')
.height('100%')
.backgroundColor('#F2F2F7')
}
// 四个页签的 @Builder 包装函数
@Builder TabBarBuilder0() { this.CustomTabContent(0) }
@Builder TabBarBuilder1() { this.CustomTabContent(1) }
@Builder TabBarBuilder2() { this.CustomTabContent(2) }
@Builder TabBarBuilder3() { this.CustomTabContent(3) }
// 核心:自定义页签内容构建器
@Builder
CustomTabContent(index: number) {
Column() {
Stack() {
Image(this.tabIcons[index])
.width(this.useLargeIcon ? 28 : 22)
.height(this.useLargeIcon ? 28 : 22)
.fillColor(index === this.currentTabIndex ? '#007AFF' : '#8E8E93')
// 角标
if (this.showBadge && this.badgeCounts[index] > 0) {
Text(this.badgeCounts[index] > 99 ? '99+' : String(this.badgeCounts[index]))
.fontSize(9)
.fontColor('#FFFFFF')
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
.backgroundColor('#FF3B30')
.borderRadius(8)
.position({ x: 14, y: -8 })
}
}
Text(this.tabLabels[index])
.fontSize(10)
.fontColor(index === this.currentTabIndex ? '#007AFF' : '#8E8E93')
.fontWeight(index === this.currentTabIndex ? FontWeight.Medium : FontWeight.Normal)
.margin({ top: 2 })
}
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.onClick(() => {
this.currentTabIndex = index;
this.controller.changeIndex(index);
})
}
@Builder
InfoCard(title: string, content: string) {
Column() {
Text(title)
.fontSize(14).fontWeight(FontWeight.Medium).fontColor('#182431')
Text(content)
.fontSize(12).fontColor('#99182431')
.margin({ top: 6 }).lineHeight(20)
}
.width('100%')
.padding(16)
.backgroundColor('#FFFFFF')
.borderRadius(12)
.alignItems(HorizontalAlign.Start)
}
}
八、CustomBuilder 设计的注意要点
在实际开发中,使用 CustomBuilder 自定义页签栏需要注意以下几点:
1. 点击区域要足够大
页签的点击热区建议不小于 48vp × 48vp,确保手指触控的准确性和舒适度。如果图标和文字本身尺寸较小,可以通过 padding 扩展点击区域。
2. 选中态要有明确的视觉反馈
仅靠颜色变化有时不够明显。可以结合字重变化(Normal → Medium)、Scale 动画(1.0 → 1.05)、或底部指示条来强化选中感知。
3. 角标信息要有节制
不要给每个页签都加上角标,只有在确实有需要用户关注的信息时才使用。过多的角标会造成视觉噪音,反而降低用户的注意力效率。
4. 性能考量
@Builder 函数会在每次状态变化时被重新调用。避免在 @Builder 内部执行复杂的计算逻辑。如果页签栏有复杂的数据处理需求,建议在 @Builder 外部完成计算,通过状态变量传入结果。
九、小结
本文从零开始介绍了使用 CustomBuilder 自定义 HdsTabs 页签栏的完整方法:
- BottomTabBarStyle vs CustomBuilder:前者开箱即用但定制性有限,后者完全自由但需要手动处理细节
- @Builder 构建器:通过 @Builder 函数定义页签 UI,在
.tabBar()中传入 - 角标系统:基于 Stack 叠加 + position 定位实现,支持数字角标、红点提示和文字标签
- 选中态样式:通过索引匹配动态切换图标颜色和文字样式
- 图标尺寸适配:灵活切换标准尺寸和大图标模式
掌握了 CustomBuilder,你就可以打造出完全符合品牌设计规范的页签栏了。无论是社交应用的角标消息、电商应用的购物车计数,还是内容平台的会员专属图标,都可以通过这种方式轻松实现。
更多推荐


所有评论(0)