HarmonyOS 底部导航栏优化:从占位符到 SymbolGlyph 的演进
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)
比第一版好了——能跳转了,当前页不会重复跳转。但还有两个问题:
- 图标还是
Circle占位符 - 跳转逻辑散落在调用方,"调配"跳
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 有几个不同:
- 布局不同:圆形大按钮,需要
.margin({ top: -24 })凸出导航栏 - 没有选中态:不需要切换颜色
- 固定行为:点击永远跳
DrinkCustomize,不需要参与 Tab 状态管理
如果硬要把 “+” 按钮塞进 TabItem 里,得加一堆特殊判断(isSpecial、isFloating 之类的参数),反而更复杂。单独处理更清晰。
三种方案的对比
| 维度 | 第一版(Circle) | 第二版(回调) | 第三版(SymbolGlyph) |
|---|---|---|---|
| 图标质量 | 占位符,不可用 | 占位符,不可用 | 系统矢量图标 |
| 状态管理 | 通过参数 isActive |
通过参数 isActive |
@State currentTab |
| 跳转逻辑 | 无 | 分散在调用方 | 集中在 onTabClick |
| 代码复杂度 | 最低 | 中等 | 中等 |
| 可扩展性 | 差 | 一般 | 好 |
第三版的优势在"可扩展性"——加一个新 Tab,只需要:
getTabSymbol里加一个 caseonTabClick里加一个跳转逻辑(如果需要跳转)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。
更多推荐



所有评论(0)