HarmonyOS 底部导航栏优化:从占位符到 SymbolGlyph 的演进

一个 TabBar 组件的三次迭代,每次都在解决不同的问题。

为什么 TabBar 值得单独写

底部导航栏是移动端首页的标配,看起来也简单——几个图标加文字,点击切换。但真正做进去才发现,这个"简单"的组件涉及状态管理、图标系统、路由跳转、视觉反馈,每个环节都有可以优化的地方。

我的饮品 App 底部导航栏改了三版,每一版都在解决上一版的问题。这篇文章把这三次迭代的过程和思路讲清楚。
完整效果
在这里插入图片描述

第一版:Circle 占位符

最早版本的 Tab 图标是用 Circle 做的占位:

@Builder TabItem(title: string, isActive: boolean) {
  Column({ space: 4 }) {
    Circle({ width: 24, height: 24 })
      .fill(isActive ? '#8A5DFA' : '#E0E0E0')
    Text(title)
      .fontSize(11)
      .fontColor(isActive ? '#8A5DFA' : '#888888')
  }
}

优点:能跑,选中/未选中颜色能切换。

问题:圆形占位符不是真正的图标,用户看不出"首页"“收藏"是什么意思。如果换成文字图标(比如"🏠”),不同设备的 emoji 渲染不一致,有的大有的小,有的有颜色有的没有。

第二版:可选回调模式

第二版加了点击跳转,用可选回调参数解决"当前页不需要跳转"的问题:

@Builder TabItem(title: string, isActive: boolean, onClick?: () => void) {
  Column({ space: 4 }) {
    Circle({ width: 24, height: 24 })
      .fill(isActive ? '#8A5DFA' : '#E0E0E0')
    Text(title)
      .fontSize(11)
      .fontColor(isActive ? '#8A5DFA' : '#888888')
  }
  .onClick(() => {
    if (onClick) { onClick() }
  })
}

使用方式:

this.TabItem('首页', true)   // 当前页,不传回调
this.TabItem('调配', false, () => {
  router.pushUrl({ url: 'pages/DrinkCustomize' })
})
this.TabItem('收藏', false)
this.TabItem('我的', false)

比第一版好了——能跳转了,当前页不会重复跳转。但还有两个问题:

  1. 图标还是 Circle 占位符
  2. 跳转逻辑散落在调用方,"调配"跳 DrinkCustomize,"收藏"跳哪里?以后加页面得一个个补回调

第三版:SymbolGlyph + 集中状态管理

最新版做了两个关键改进:用 SymbolGlyph 替换占位图标,用 currentTab 状态变量集中管理 Tab 状态。

SymbolGlyph:HarmonyOS 的系统图标

private getTabSymbol(index: number, isActive: boolean): Resource {
  if (isActive) {
    switch (index) {
      case 0: return $r('sys.symbol.house_fill')
      case 1: return $r('sys.symbol.star_fill')
      case 2: return $r('sys.symbol.heart_fill')
      case 3: return $r('sys.symbol.person_fill')
      default: return $r('sys.symbol.circle_fill')
    }
  }
  switch (index) {
    case 0: return $r('sys.symbol.house')
    case 1: return $r('sys.symbol.star')
    case 2: return $r('sys.symbol.heart')
    case 3: return $r('sys.symbol.person')
    default: return $r('sys.symbol.circle')
  }
}

SymbolGlyph 是 HarmonyOS 提供的矢量图标组件,和 iOS 的 SF Symbols 类似。sys.symbol.* 是系统内置图标,不需要额外加载资源文件。

选中态用 _fill 后缀(实心),未选中用无后缀版本(空心)。视觉上一眼就能区分当前 Tab。
在这里插入图片描述

使用方式:

SymbolGlyph(this.getTabSymbol(index, this.currentTab === index))
  .fontSize(24)
  .fontColor([this.currentTab === index ? '#8A5DFA' : '#888888'])

.fontColor() 接收一个数组——这是 SymbolGlyph 的特殊设计。单色图标用数组的第一个颜色,多色图标的每个通道对应数组中的一个颜色。这里只用单色,所以数组只有一个元素。

和 emoji 的对比:

维度 SymbolGlyph Emoji
渲染一致性 所有设备一致 不同设备差异大
矢量缩放 任意缩放不失真 位图,放大模糊
颜色控制 通过 fontColor 设置 无法控制
包大小 系统内置,0 额外开销 依赖系统字体
图标丰富度 系统内置数百个 受限于 Unicode

如果你的 App 用的是常见图标(首页、搜索、收藏、个人中心),优先用 SymbolGlyph,省资源、效果好。

currentTab:集中管理 Tab 状态

@State currentTab: number = 0

private onTabClick(index: number): void {
  this.currentTab = index
  if (index === 1) {
    router.pushUrl({ url: 'pages/DrinkCustomize' })
  }
}

所有 Tab 的点击逻辑集中在 onTabClick 方法里。当前选中哪个 Tab、点击后跳转到哪个页面,都在这一个地方处理。
在这里插入图片描述

TabItem 构建器也简化了——不再需要可选回调:

@Builder TabItem(index: number, title: string) {
  Column({ space: 4 }) {
    SymbolGlyph(this.getTabSymbol(index, this.currentTab === index))
      .fontSize(24)
      .fontColor([this.currentTab === index ? '#8A5DFA' : '#888888'])
    Text(title)
      .fontSize(11)
      .fontColor(this.currentTab === index ? '#8A5DFA' : '#888888')
  }
  .onClick(() => {
    this.onTabClick(index)
  })
}

在这里插入图片描述

使用方式:

this.TabItem(0, '首页')
this.TabItem(1, '调配')
this.TabItem(2, '收藏')
this.TabItem(3, '我的')

比上一版干净多了——调用方只传索引和标题,不需要关心跳转逻辑。

中间 “+” 按钮的特殊处理

Button() {
  Text('+').fontSize(32).fontColor(Color.White).fontWeight(FontWeight.Lighter)
}
.type(ButtonType.Circle)
.width(56)
.height(56)
.backgroundColor('#8A5DFA')
.margin({ top: -24 })
.shadow({ radius: 10, color: 'rgba(138, 93, 250, 0.4)', offsetY: 4 })
.onClick(() => {
  router.pushUrl({ url: 'pages/DrinkCustomize' })
})

在这里插入图片描述

“+” 按钮没有走 TabItem 构建器,因为它和普通 Tab 有几个不同:

  1. 布局不同:圆形大按钮,需要 .margin({ top: -24 }) 凸出导航栏
  2. 没有选中态:不需要切换颜色
  3. 固定行为:点击永远跳 DrinkCustomize,不需要参与 Tab 状态管理

如果硬要把 “+” 按钮塞进 TabItem 里,得加一堆特殊判断(isSpecial、isFloating 之类的参数),反而更复杂。单独处理更清晰。

三种方案的对比

维度 第一版(Circle) 第二版(回调) 第三版(SymbolGlyph)
图标质量 占位符,不可用 占位符,不可用 系统矢量图标
状态管理 通过参数 isActive 通过参数 isActive @State currentTab
跳转逻辑 分散在调用方 集中在 onTabClick
代码复杂度 最低 中等 中等
可扩展性 一般

第三版的优势在"可扩展性"——加一个新 Tab,只需要:

  1. getTabSymbol 里加一个 case
  2. onTabClick 里加一个跳转逻辑(如果需要跳转)
  3. BottomNavigation 里加一行 this.TabItem(4, '新Tab')

不需要改 TabItem 构建器本身。

踩坑记录

1. SymbolGlyph 的 fontColor 必须是数组

// ❌ 编译错误
.fontColor('#8A5DFA')

// ✅ 正确
.fontColor(['#8A5DFA'])

SymbolGlyph.fontColor() 签名和 Text 不同,它接收 ResourceColor[] 而不是 ResourceColor。忘了加数组括号是最常见的编译错误。

2. sys.symbol 后缀区别

$r('sys.symbol.house')      // 空心图标
$r('sys.symbol.house_fill') // 实心图标

后缀 _fill 表示实心版本。不是所有图标都有 _fill 版本,使用前最好在 DevEco Studio 的预览里确认一下。

3. currentTab 不控制页面渲染

currentTab 只是记录当前选中了哪个 Tab,它不控制内容区的显示。首页的内容区始终是同一个 Scroll,不会根据 currentTab 切换显示不同内容。

如果要实现"Tab 切换显示不同内容"(类似微信底部 Tab),需要在 build() 里加条件渲染:

if (this.currentTab === 0) {
  // 首页内容
} else if (this.currentTab === 2) {
  // 收藏内容
}

但这种方案在 Tab 数量多时代码会很臃肿,更好的做法是用 Tabs 组件。对于我们的场景——大部分 Tab 是跳转到新页面——currentTab 只管选中态就够了。

4. “+” 按钮的 margin 负值

.margin({ top: -24 }) 让按钮向上凸出导航栏。这个值需要和导航栏高度匹配:

  • 导航栏高度 70px
  • 按钮直径 56px
  • 凸出量 = (70 - 56) / 2 = 7px,再加一点视觉突出 → -24px

如果改了导航栏高度,记得同步调整这个负值。

最后

底部导航栏看着简单,但从"能用"到"好用"经历了三次迭代。每一版都在解决上一版的问题——第一版没有真图标,第二版图标还是占位符且跳转逻辑分散,第三版用 SymbolGlyph + 集中状态管理才算是个正经的 TabBar。

Logo

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

更多推荐