在这里插入图片描述

一、引言

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

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);
  })
}

这个构建器使用了三层结构:

  1. Stack 容器承载图标和角标的叠加布局
  2. 条件渲染的角标在特定条件下出现在图标右上角
  3. 文字标签独立放置在图标下方

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,你就可以打造出完全符合品牌设计规范的页签栏了。无论是社交应用的角标消息、电商应用的购物车计数,还是内容平台的会员专属图标,都可以通过这种方式轻松实现。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐