HarmonyOS应用<民族图鉴>开发第12篇:首页框架——Tabs容器与自定义TabBar深度解析

📖 引言
启动页动画播完,用户进入应用,第一眼看到的是什么?是首页。
而几乎所有 App 的首页,底部都有那么一排图标——首页、发现、消息、我的… 这个东西就是 TabBar(标签栏),上面承载内容的容器叫 Tabs。
别小看这个底部导航,它可是应用的"骨架":
- 它定义了应用有哪些主要功能模块
- 用户 80% 的时间都是在这几个 Tab 之间切换
- 它的设计好不好,直接决定了应用的整体体验
「民族图鉴」的底部有 5 个 Tab:首页、百科、地图、测验、我的。每个 Tab 对应一个独立的页面组件,TabBar 是自定义的,图标 + 文字,选中时变主色调。
这一篇我们就来拆解首页框架:
- Tabs 组件到底是怎么工作的?
- TabContent 怎么和 TabBar 关联?
- 自定义 TabBar 怎么实现?
- Tab 切换的性能优化怎么做?
- 动态 Tab(根据模式显示/隐藏)怎么处理?
掌握了 Tabs,你就掌握了应用的主框架。让我们开始吧。
🎯 学习目标
完成本文后,你将能够:
- ✅ 理解 Tabs 组件的工作原理与核心概念
- ✅ 掌握 TabContent 与 tabBar 的关联方式
- ✅ 学会用 @Builder 自定义 TabBar 样式
- ✅ 理解 Tabs 的生命周期与懒加载机制
- ✅ 掌握动态 Tab 的实现(条件渲染 Tab)
- ✅ 了解 Tabs 的性能优化技巧
- ✅ 写出结构清晰、性能优良的首页框架
🎨 底部导航栏的设计原则
在开始写代码之前,我们先聊聊设计。底部 TabBar 看起来简单,但要做好并不容易。它是用户使用频率最高的导航组件,设计得好不好,直接影响用户体验。
原则一:Tab 数量 3-5 个,不多不少
| 数量 | 体验 | 评价 |
|---|---|---|
| 2个 | 太空了,浪费 TabBar 空间 | ❌ 不推荐 |
| 3个 | 宽松舒适,每个 Tab 都很大 | ✅ 好 |
| 4个 | 刚刚好,大多数 App 的选择 | ✅ 最佳 |
| 5个 | 有点挤,但还能用 | ✅ 上限 |
| 6个+ | 太挤了,点不准,记不住 | ❌ 不推荐 |
为什么最多 5 个?
- 拇指热区有限:底部就那么宽,5 个 Tab 已经是极限了,再多每个 Tab 的宽度就太小了,点不准
- 记忆负担:人一次能记住的东西是 7±2 个,但导航 Tab 最好控制在 5 个以内,用户不用想就能找到
- 重要性法则:如果有 6、7 个 Tab,说明你把不重要的功能也放到底部了,该精简了
看看你手机上最常用的 App:微信 4 个、支付宝 5 个、抖音 5 个、小红书 5 个… 几乎没有超过 5 个的。这是行业共识。
原则二:图标 + 文字,不要只有图标或只有文字
| 样式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 只有图标 | 简洁、节省空间 | 用户可能猜不出什么意思 | 用户量极大、人人都用的超级 App |
| 只有文字 | 清晰易懂 | 不够直观、有点单调 | 工具类、文字属性强的应用 |
| 图标 + 文字 | 直观易懂,记忆成本低 | 占用空间稍大 | 绝大多数应用(推荐) |
「民族图鉴」为什么选图标 + 文字?
- 我们是文化类应用,用户群体年龄跨度大,光看图可能不知道什么意思
- 文字能准确传达 Tab 的功能,降低用户的学习成本
- 5 个 Tab,图标 + 文字也放得下,不会太挤
💡 什么时候可以只有图标? 如果你的 App 是像微信、抖音那样的国民级应用,用户已经形成肌肉记忆了,光看图就知道哪个是哪个,可以考虑只有图标。但大多数应用,老实上图标 + 文字吧。
原则三:选中态要明显,用户一眼知道自己在哪
用户切换 Tab 的时候,必须能立刻反馈——“我点了,切过去了”。
选中态的设计方式:
| 方式 | 效果 | 推荐度 |
|---|---|---|
| 颜色变化(最常用) | 选中变主色,未选中灰色 | ⭐⭐⭐⭐⭐ |
| 图标变化(填充/线框) | 选中是填充图标,未选中是线框图标 | ⭐⭐⭐⭐ |
| 文字加粗 | 选中文字加粗 | ⭐⭐⭐ |
| 大小变化 | 选中图标放大一点 | ⭐⭐⭐ |
| 背景色变化 | 选中的 Tab 背景变个色 | ⭐⭐ |
| 上移动画 | 选中的 Tab 稍微往上移一点 | ⭐⭐ |
「民族图鉴」的选择:颜色变化——选中变主色调,未选中灰色。
为什么?
- 最简单直接,用户一眼就能看懂
- 实现成本最低,代码最简单
- 和整体设计风格一致,不花哨
💡 不要搞太复杂的选中态。底部导航是基础组件,不是炫技的地方。用户用了三个月之后,根本不会注意到选中态是什么样的——但如果没有明显的选中态,用户会困惑"我现在在哪?“。选中态的目标是"清晰”,不是"炫酷"。
原则四:点击区域够大,至少 44×44vp
移动端有个"44dp 法则"——触控目标的最小尺寸是 44×44dp(在鸿蒙里是 vp)。太小了手指点不准,用户会抓狂。
TabBar 的标准高度是 56vp,每个 Tab 的宽度是屏幕宽度除以 Tab 数量。以 5 个 Tab 为例:
- 高度:56vp ✅ 远超 44vp
- 宽度:屏幕宽度 / 5,在手机上一般有 70-80vp ✅ 也够大
为什么是 44vp?
根据研究,成年人的手指触控区域大约是 7-10mm,换算成 dp 大概是 40-50dp。44dp 是苹果 iOS 设计规范里定的,后来成了行业标准。鸿蒙的 vp 和 Android 的 dp 是类似的概念,所以也适用。
记住:宁大勿小。触控目标大一点,用户最多觉得"有点空";但如果太小了,用户点不准会骂人的。
原则五:Tab 位置固定,不要变来变去
用户用了几天之后,会形成肌肉记忆——“首页在最左边,我的在最右边”。如果 Tab 位置经常变,用户会很困惑。
「民族图鉴」的实践:
- 首页永远在第 0 个(最左边)
- 我的永远在最后一个(最右边)
- 即使是基础模式(只有 3 个 Tab),首页、百科、我的顺序也不变
这也是为什么「民族图鉴」基础模式下,我们隐藏的是中间的地图和测验,而不是从后面删。因为用户已经习惯了"首页在第一个,我的在最后一个",动中间的比动两边的影响小。
🏛️ 「民族图鉴」5个Tab的设计思路与信息架构
了解了设计原则,我们来看看「民族图鉴」的 5 个 Tab 是怎么设计的。为什么是这 5 个?为什么是这个顺序?
5个Tab的信息架构
┌─────────────────────────────────────────────────────┐
│ 民族图鉴 App │
├──────────┬──────────┬────────┬────────┬────────────┤
│ 首页 │ 百科 │ 地图 │ 测验 │ 我的 │
│ (发现) │ (内容) │ (探索) │ (互动) │ (个人中心) │
└──────────┴──────────┴────────┴────────┴────────────┘
每个Tab的定位与内容
Tab 0:首页(发现页)
定位:用户进入应用的第一站,内容推荐 + 快速入口。
核心内容:
- 搜索框:快速找到想看的民族
- 每日冷知识:每天一个小知识点,增加用户粘性
- 精选民族:编辑推荐的重点民族
- 快捷入口:8 个常用功能的快捷入口
- 更多推荐:按分类推荐的民族列表
设计思路:
- 首页要"热闹"一点,让用户一进来就有东西可看
- 但也不能太乱,要有清晰的信息层级
- 搜索放在最顶部,符合用户习惯
- 冷知识卡片增加趣味性和每日打开的理由
首页的目标是"发现"——用户不知道想看什么的时候,来首页逛逛,总能找到感兴趣的内容。
Tab 1:百科(内容页)
定位:56 个民族的完整资料库,内容浏览的核心页面。
核心内容:
- 56 个民族完整列表
- 分类筛选(按地区、按语系、按人口等)
- 搜索功能
- 网格视图 / 列表视图切换
设计思路:
- 百科是内容的大本营,用户想系统了解某个民族,就来这里
- 列表 + 搜索 + 筛选,是内容型应用的标配
- 提供多种视图方式,满足不同用户的偏好
为什么叫"百科"不叫"民族"?因为"百科"暗示了内容的丰富性和系统性——不止是列表,还有详细的介绍、图片、视频等。叫"民族"的话,用户可能以为就只是个名单。
Tab 2:地图(探索页)
定位:地理视角的民族分布,可视化探索。
核心内容:
- 中国地图,标注各民族主要分布区域
- 点击地图上的区域,查看当地的民族
- 按民族查看分布地图
- 地理相关的民族知识
设计思路:
- 地图是「民族图鉴」的特色功能,很多民族应用没有这个
- 可视化的方式更直观,用户一眼就能看出民族分布
- 增加应用的趣味性和探索性
地图 Tab 放在中间(第 2 个),因为它是特色功能,但不是最高频的功能。最高频的是首页和百科,放两边(用户的拇指在两边移动更自然)。
Tab 3:测验(互动页)
定位:知识测验 + 游戏化,增加用户粘性和学习趣味性。
核心内容:
- 民族知识答题(单选、多选、判断)
- 难度分级(简单、中等、困难)
- 错题本
- 答题记录 / 成就系统
- 排行榜(可选)
设计思路:
- 纯浏览太枯燥了,加个测验增加互动性
- 游戏化的方式让学习更有趣
- 错题本帮助用户查漏补缺
- 成就系统给用户成就感,促进持续使用
为什么把测验放第 3 个?因为它是"加分项"——有了更好,没有也不影响核心功能。核心功能是首页、百科、我的,这三个必须有。测验和地图是特色功能,放中间。
Tab 4:我的(个人中心)
定位:用户数据、设置、个人收藏的汇总页面。
核心内容:
- 用户信息(头像、昵称)
- 我的收藏
- 浏览历史
- 我的测验(成绩、错题)
- 设置(主题、语言、模式切换)
- 关于我们
设计思路:
- 个人中心放最右边,是行业惯例,用户已经习惯了
- 用户的私人数据都放这里,清晰明了
- 设置也放这里,符合用户的心理模型
“我的"放最后一个,几乎是所有 App 的共识。用户找"我的”,直接往最右边点就对了。不要乱改位置。
为什么是这个顺序?
Tab 的顺序不是随便排的,背后有用户行为和信息架构的考量:
| 位置 | Tab | 为什么在这 |
|---|---|---|
| 第0个(最左) | 首页 | 用户打开 App 首先看首页,默认第一个 |
| 第1个 | 百科 | 核心内容浏览,第二高频 |
| 第2个(中间) | 地图 | 特色功能,但不是最高频 |
| 第3个 | 测验 | 互动功能,锦上添花 |
| 第4个(最右) | 我的 | 个人中心,惯例放最后 |
排序的底层逻辑:
- 使用频率从左到右递减?不完全是——首页最高频放左边,"我的"频率也不低但放右边
- 更准确的逻辑是:内容/发现从左往右,个人中心在最右
- 左边是"逛内容",右边是"我的东西",中间是特色功能
你可以观察一下主流 App 的 Tab 排序:微信(微信-通讯录-发现-我)、支付宝(首页-理财-生活-消息-我的)、抖音(首页-朋友-中间的+号-消息-我)。虽然具体功能不同,但底层逻辑是类似的——左边是内容/发现,右边是个人中心,中间是特色/发布。
基础模式为什么只留 3 个 Tab?
基础模式(内容模式=basic)下,我们只保留 3 个 Tab:首页、百科、我的。
为什么这么设计?
- 核心功能保留:首页(发现)、百科(内容)、我的(个人中心),这三个构成了一个内容型应用的最小闭环
- 特色功能隐藏:地图和测验是特色功能,但不是核心功能。内容模式下,用户更关注内容本身
- 降低认知负担:Tab 少了,界面更简洁,用户更容易上手
- 渐进式披露:先用简单的版本吸引用户,等用户有兴趣了,再解锁更多功能
这也是一种产品设计思路——MVP(最小可行产品)。先做核心功能,验证用户需求,再慢慢加功能。「民族图鉴」的基础模式,就相当于一个 MVP。
💡 需求分析
为什么需要 Tabs?
一个应用通常有好几个主要功能模块,怎么组织?
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 列表菜单 | 简单直接,想加多少加多少 | 操作层级深,每次要回去选 | 设置页、工具类应用 |
| 侧边抽屉 | 节省空间,可放很多功能 | 不直观,用户不容易发现 | 内容型应用、侧边菜单 |
| 底部 TabBar | 直观可见,一键切换,操作成本最低 | Tab 数量有限(建议 3-5 个) | 主流应用,核心功能导航 |
底部 TabBar 是目前移动端应用最主流的导航方式,因为:
- 拇指热区:手机屏幕底部是单手拇指最容易触达的区域
- 所见即所得:有哪些功能一目了然,不用猜
- 切换成本低:点一下就切过去了,不用返回重选
所以几乎所有你常用的 App——微信、支付宝、抖音、小红书… 底部都是 TabBar。
「民族图鉴」的 Tab 设计
我们的应用有 5 个主 Tab:
| Tab 序号 | 名称 | 图标 | 页面 | 说明 |
|---|---|---|---|---|
| 0 | 首页 | 🏠 | HomePage | 搜索、冷知识、精选民族、快捷入口、推荐 |
| 1 | 百科 | 📖 | EthnicListPage | 56个民族列表、分类筛选、搜索 |
| 2 | 地图 | 🗺️ | MapPage | 民族地理分布可视化 |
| 3 | 测验 | ✏️ | QuizPage | 知识测验、错题本、答题记录 |
| 4 | 我的 | 👤 | ProfilePage | 用户信息、收藏、历史、设置 |
但还有个特殊情况:基础模式(内容模式=basic)下,只保留 3 个 Tab(首页、百科、我的),地图和测验要隐藏。这就是动态 Tab 的需求。
设计原则
TabBar 的设计有一些行业公认的最佳实践:
| 原则 | 说明 | 为什么 |
|---|---|---|
| Tab 数量 3-5 个 | 最少 3 个,最多 5 个 | 太少了浪费 TabBar,太多了挤不下、也记不住 |
| 图标 + 文字 | 不要只有图标或只有文字 | 光看图可能不知道什么意思,光看文字又不直观 |
| 选中态明显 | 选中的 Tab 要和未选中的有明显区别 | 用户要能一眼看出"我现在在哪" |
| 点击区域够大 | 每个 Tab 的点击区域至少 44×44vp | 太小了点不准,用户体验差 |
| 切换不刷新 | 切换 Tab 不重新创建页面 | 每次切回去都重新加载,体验差 |
🛠️ 核心实现
步骤1:Tabs 组件基础
1.1 Tabs 的基本结构
Tabs 组件由两部分组成:
- TabContent:Tab 的内容区,每个 Tab 对应一个 TabContent
- TabBar:底部的标签栏,用来切换 Tab
// 最简单的 Tabs 示例
Tabs({ barPosition: BarPosition.End, index: 0 }) {
TabContent() {
Text('首页内容')
}
.tabBar('首页')
TabContent() {
Text('百科内容')
}
.tabBar('百科')
TabContent() {
Text('我的内容')
}
.tabBar('我的')
}
.width('100%')
.height('100%')
关键参数:
| 参数 | 类型 | 说明 |
|---|---|---|
barPosition |
BarPosition |
TabBar 位置:Start(顶部)/ End(底部) |
index |
number |
当前选中的 Tab 索引,从 0 开始 |
onChange |
(index: number) => void |
Tab 切换时的回调 |
1.2 barPosition——TabBar 在哪?
BarPosition.Start:TabBar 在顶部(类似 Android 的 Top Tabs)BarPosition.End:TabBar 在底部(最常用,底部导航)
「民族图鉴」用的是 BarPosition.End,底部导航。
1.3 index——当前在哪个 Tab?
index 属性控制当前显示哪个 Tab,绑定一个 @State 变量,就能通过代码控制切换了。
@State currentIndex: number = 0;
Tabs({ index: this.currentIndex }) {
TabContent() { /*...*/ }
TabContent() { /*...*/ }
}
.onChange((index: number) => {
this.currentIndex = index; // 用户点击切换时,更新状态
})
// 代码里也可以手动切换
private switchToProfile(): void {
this.currentIndex = 4; // 切到"我的"
}
1.4 onChange——切换事件
用户点击 Tab 切换时,会触发 onChange 回调,告诉你切到了第几个 Tab。
.onChange((index: number) => {
this.currentIndex = index;
console.info('切换到 Tab:' + index);
// 可以在这里打点、埋点、切换数据等
})
1.5 更多实用属性详解
除了上面几个基础属性,Tabs 还有很多实用属性,这里挑几个常用的讲一讲。
barMode——TabBar 的布局模式
barMode 控制 TabBar 中每个 Tab 的宽度怎么分配:
BarMode.Fixed:固定宽度,所有 Tab 平分 TabBar 宽度(底部导航常用)BarMode.Scrollable:可滚动,每个 Tab 宽度由内容决定,Tab 多了可以横向滚动(顶部分类常用)
// 底部导航:固定宽度,平分
Tabs() { ... }
.barMode(BarMode.Fixed)
// 顶部分类:可滚动
Tabs() { ... }
.barMode(BarMode.Scrollable)
| 模式 | 适用场景 | Tab数量 |
|---|---|---|
| Fixed | 底部导航 | 3-5个 |
| Scrollable | 顶部分类、频道切换 | 很多(6个以上) |
「民族图鉴」用的是默认的 Fixed 模式,因为底部 5 个 Tab 刚好平分屏幕宽度。
barBackgroundColor——TabBar 背景色
设置 TabBar 的背景颜色:
Tabs() { ... }
.barBackgroundColor('#FFFFFF') // 白色背景
注意:
barBackgroundColor设置的是系统 TabBar 的背景色。如果你用自定义 TabBar(@Builder),背景色要在你的自定义组件里设置。
animationDuration——切换动画时长
Tab 切换的时候,默认有个左右滑动的动画。用 animationDuration 可以控制动画时长,单位是毫秒。
Tabs() { ... }
.animationDuration(300) // 动画时长 300ms
如果不想要切换动画,设为 0 就可以:
.animationDuration(0) // 没有动画,瞬间切换
一般来说,300ms 左右是比较舒服的切换速度——不会太快显得突兀,也不会太慢让用户等。
vertical——竖向 Tabs?
默认 Tabs 是横向的(左右滑动切换),设置 vertical: true 可以变成竖向的(上下滑动切换)。
Tabs({ vertical: true }) {
TabContent() { Text('上一页') }
TabContent() { Text('下一页') }
}
竖向 Tabs 用得不多,但在一些特殊场景(比如电子书翻页、图片浏览)很有用。
barHeight——TabBar 高度
设置 TabBar 的高度:
Tabs() { ... }
.barHeight(56) // TabBar 高度 56vp
💡 移动端 TabBar 标准高度是多少? iOS 是 49pt,Android Material Design 是 56dp,鸿蒙一般也用 56vp。我们「民族图鉴」的自定义 TabBar 高度就是 56vp,符合行业标准。
步骤2:自定义 TabBar——用 @Builder 打造专属样式
tabBar() 不仅可以传字符串,还可以传自定义组件——用 @Builder 装饰器。
这就给了我们完全的自由度,想做成什么样就做成什么样。
2.1 最简单的自定义 TabBar
Tabs() {
TabContent() {
Text('首页')
}
.tabBar(this.customTab('首页', 0))
}
@Builder
customTab(title: string, index: number) {
Column({ space: 4 }) {
Text('🏠').fontSize(22)
Text(title).fontSize(10)
}
.width('100%')
.height(56)
.justifyContent(FlexAlign.Center)
}
2.2 「民族图鉴」的 TabBar 实现
我们的 TabBar 是"图标 + 文字"的经典样式,选中时变主色调:
// pages/Index.ets
@State currentIndex: number = 0;
// Tab 数据配置
private tabs: Array<TabItem> = [
{ title: 'tab_home', icon: '\u{1F3E0}' }, // 🏠 首页
{ title: 'tab_encyclopedia', icon: '\u{1F4D6}' }, // 📖 百科
{ title: 'tab_map', icon: '\u{1F5FA}\u{FE0F}' }, // 🗺️ 地图
{ title: 'tab_quiz', icon: '\u{270F}\u{FE0F}' }, // ✏️ 测验
{ title: 'tab_profile', icon: '\u{1F464}' } // 👤 我的
];
// ========== Tabs 主体 ==========
Tabs({ barPosition: BarPosition.End, index: this.currentIndex }) {
TabContent() {
this.buildHomePage()
}
.tabBar(this.tabBuilder(this.tabs[0].title, this.tabs[0].icon, 0))
TabContent() {
EthnicListPage()
}
.tabBar(this.tabBuilder(this.tabs[1].title, this.tabs[1].icon, 1))
TabContent() {
MapPage()
}
.tabBar(this.tabBuilder(this.tabs[2].title, this.tabs[2].icon, 2))
TabContent() {
QuizPage()
}
.tabBar(this.tabBuilder(this.tabs[3].title, this.tabs[3].icon, 3))
TabContent() {
ProfilePage()
}
.tabBar(this.tabBuilder(this.tabs[4].title, this.tabs[4].icon, 4))
}
.onChange((index: number) => {
this.currentIndex = index;
})
.width('100%')
.height('100%')
.barBackgroundColor('#FFFFFF')
// ========== 自定义 TabBar 构建器 ==========
@Builder
tabBuilder(tabTitle: string, icon: string, targetIndex: number): void {
Column({ space: 4 }) {
// 图标
Text(icon)
.fontSize(22)
.fontColor(
this.currentIndex === targetIndex
? $r('app.color.primary_color') // 选中:主色调
: $r('app.color.text_hint') // 未选中:提示灰色
)
// 文字
Text($r(`app.string.${tabTitle}`))
.fontSize(10)
.fontColor(
this.currentIndex === targetIndex
? $r('app.color.primary_color')
: $r('app.color.text_hint')
)
}
.width('100%')
.height(56)
.justifyContent(FlexAlign.Center)
}
实现要点:
- 图标用 Unicode:
\u{1F3E0}这种是 emoji 的 Unicode 编码,直接用 Text 组件就能显示,不用找图标字体或图片,简单方便 - 选中态判断:
this.currentIndex === targetIndex,相等就是选中状态,颜色用主色调;不相等就是未选中,用灰色 - 文字用资源:
$r('app.string.' + tabTitle),配合国际化,自动显示对应语言 - 统一尺寸:每个 Tab 高度 56vp,这是移动端的标准高度,保证点击区域够大
💡 为什么用 emoji 当图标? 因为简单省事啊!不用找设计师切图,不用引入图标库,Unicode 里几千个 emoji 够用了。当然,商业项目还是建议用专业的图标库或 SVG 图标,更精致、风格更统一。但对于快速开发、个人项目,emoji 真的很香。
步骤2.5:Badge 角标——小红点、未读数的实现
很多 App 的 Tab 上会有小红点或者数字角标,比如微信的消息 Tab 上有未读消息数。这个怎么实现?
什么是 Badge?
Badge(角标)是 Tab 上的小标记,通常用来提示用户有新内容或者未读消息。
常见的 Badge 类型:
| 类型 | 效果 | 适用场景 |
|---|---|---|
| 小红点 | 一个小红点,没有数字 | 有新内容、新功能提示 |
| 数字角标 | 红色圆形背景 + 白色数字 | 未读消息数、待办数量 |
| 文字角标 | 红色背景 + 文字(如"NEW") | 新功能、新活动提示 |
| 圆点 + 数字 | 小圆点 + 数字组合 | 复杂的提醒场景 |
「民族图鉴」的 Badge 需求
我们的应用里,哪些地方需要 Badge 呢?
- 首页 Tab:如果有新的冷知识、新推荐,可以加个小红点
- 测验 Tab:有新的测验题目上线了,加个"NEW"角标
- 我的 Tab:有未读消息或者新的成就,加个数字角标
Badge 的实现思路
因为我们用了自定义 TabBar(@Builder),所以加 Badge 很简单——在 Tab 的图标右上角叠一个小标记就行了。
用什么布局?Stack 布局——把图标和 Badge 叠在一起,Badge 放在右上角。
代码实现:小红点 Badge
先实现最简单的小红点:
@Builder
tabBuilder(tabTitle: string, icon: string, targetIndex: number, showBadge: boolean = false): void {
Column({ space: 4 }) {
// 图标 + Badge 用 Stack 叠在一起
Stack() {
// 图标
Text(icon)
.fontSize(22)
.fontColor(
this.currentIndex === targetIndex
? $r('app.color.primary_color')
: $r('app.color.text_hint')
)
// 小红点 Badge
if (showBadge) {
Circle()
.width(8)
.height(8)
.fillColor('#FF4D4F') // 红色
.position({ x: 18, y: -2 }) // 定位到右上角
}
}
.width(26)
.height(26)
.justifyContent(FlexAlign.Center)
// 文字
Text($r(`app.string.${tabTitle}`))
.fontSize(10)
.fontColor(
this.currentIndex === targetIndex
? $r('app.color.primary_color')
: $r('app.color.text_hint')
)
}
.width('100%')
.height(56)
.justifyContent(FlexAlign.Center)
}
关键点:
- 用
Stack把图标和 Badge 叠在一起 - Badge 用
Circle组件画一个红色小圆点 - 用
position把小圆点定位到图标右上角 - 用
showBadge参数控制是否显示
代码实现:数字角标
如果要显示未读数量,就需要数字角标:
@Builder
tabBuilder(tabTitle: string, icon: string, targetIndex: number,
showBadge: boolean = false, badgeCount: number = 0): void {
Column({ space: 4 }) {
Stack() {
// 图标
Text(icon)
.fontSize(22)
.fontColor(
this.currentIndex === targetIndex
? $r('app.color.primary_color')
: $r('app.color.text_hint')
)
// 数字角标
if (showBadge && badgeCount > 0) {
Text(badgeCount > 99 ? '99+' : badgeCount.toString())
.fontSize(10)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.minWidth(16)
.height(16)
.padding({ left: 4, right: 4 })
.backgroundColor('#FF4D4F')
.borderRadius(8)
.position({ x: 16, y: -4 })
}
}
.width(26)
.height(26)
.justifyContent(FlexAlign.Center)
// 文字(略)
}
.width('100%')
.height(56)
.justifyContent(FlexAlign.Center)
}
设计细节:
- 数字 1 位数:角标是 16×16 的圆
- 数字 2 位数:角标宽度自动撑开,高度还是 16,圆角 8(胶囊形状)
- 超过 99:显示"99+",不显示更大的数字(用户看到"99+"就知道很多了)
💡 为什么超过 99 显示 99+? 因为三位数的数字太长了,放在 Tab 上会很难看,而且用户也不关心具体是 100 还是 200,只要知道"很多"就行了。这是行业惯例,微信、钉钉都是这么做的。
「民族图鉴」的 Badge 实践
在「民族图鉴」里,我们用 Badge 来做什么呢?
场景1:每日冷知识更新提示
- 每天第一次打开应用,首页 Tab 上有个小红点
- 用户点进首页,看到了今日冷知识,小红点消失
场景2:新测验上线提示
- 有新的测验题目上线时,测验 Tab 上显示"NEW"角标
- 用户进入测验 Tab 后,角标消失
场景3:新成就解锁提示
- 用户解锁了新的成就,“我的” Tab 上显示小红点
- 用户进入"我的"页面查看成就后,小红点消失
注意:Badge 是一种"轻量提醒",不要滥用。如果每个 Tab 上都有角标,用户反而会麻木,效果适得其反。好的 Badge 设计应该是"偶尔出现,出现就有价值"。
步骤2.6:Tab 切换过渡动画——让切换更丝滑
Tab 切换的时候,默认有个左右滑动的动画。但有时候我们想要更丰富的切换效果——比如淡入淡出、缩放、甚至自定义动画。
默认的切换动画
默认情况下,Tabs 的切换是左右滑动的:
- 从左往右切:页面从右边滑进来
- 从右往左切:页面从左边滑进来
这个效果很自然,符合用户直觉,大多数情况下用默认的就挺好。
控制动画时长
用 animationDuration 属性控制动画时长:
Tabs() {
// ...
}
.animationDuration(300) // 300ms,默认值差不多就是这个
| 时长 | 效果 | 推荐度 |
|---|---|---|
| 0ms | 瞬间切换,没有动画 | ⭐⭐ 特殊场景用 |
| 150ms | 很快,感觉很利落 | ⭐⭐⭐ 工具类应用 |
| 300ms | 刚刚好,自然流畅 | ⭐⭐⭐⭐⭐ 推荐 |
| 500ms | 有点慢 | ⭐⭐ 不推荐 |
| 1000ms | 太慢了,用户着急 | ⭐ 不推荐 |
取消切换动画
不想要切换动画?直接设为 0:
Tabs() {
// ...
}
.animationDuration(0) // 没有动画
什么场景下需要取消动画?
- Tab 内容差异很大,滑动动画看起来不自然
- 对性能要求极高,不想有任何动画开销
- 特殊的设计需求
自定义切换效果:淡入淡出
如果你想要淡入淡出的效果,而不是左右滑动,怎么办?
方法:不用 Tabs 的默认动画,自己用透明度动画实现。
思路是:
- 把 Tabs 的动画时长设为 0(取消默认动画)
- 给每个 TabContent 的内容加透明度动画
- Tab 切换时,旧内容淡出,新内容淡入
@Entry
@Component
struct FadeTabsDemo {
@State currentIndex: number = 0;
@State contentOpacity: number = 1;
private switchTab(index: number): void {
if (index === this.currentIndex) return;
// 先淡出
animateTo({ duration: 150, curve: Curve.EaseIn }, () => {
this.contentOpacity = 0;
});
// 切换 Tab,再淡入
setTimeout(() => {
this.currentIndex = index;
animateTo({ duration: 200, curve: Curve.EaseOut }, () => {
this.contentOpacity = 1;
});
}, 150);
}
build() {
Tabs({ barPosition: BarPosition.End, index: this.currentIndex }) {
TabContent() {
Text('首页内容')
.width('100%')
.height('100%')
.opacity(this.contentOpacity)
}
.tabBar('首页')
TabContent() {
Text('百科内容')
.width('100%')
.height('100%')
.opacity(this.contentOpacity)
}
.tabBar('百科')
}
.animationDuration(0) // 关掉默认动画
.onChange((index: number) => {
// 注意:这里会触发循环,需要特殊处理
// 实际项目中建议用自定义 TabBar + 点击事件来控制
})
.width('100%')
.height('100%')
}
}
注意:这个方案有点复杂,因为 Tabs 的 onChange 和你手动设置 index 会互相影响。实际项目中,如果你想要完全自定义的切换动画,建议不用 Tabs 组件,自己用 if/else + 动画来实现。但这样就失去了 Tabs 的懒加载、缓存等好处。
「民族图鉴」的选择
「民族图鉴」用的是默认的左右滑动动画,300ms 左右的时长。
为什么不搞花里胡哨的?
- 默认的左右滑动最自然,用户已经习惯了
- 实现简单,不容易出 bug
- 性能好,系统层面优化过
- 文化类应用走稳重路线,不搞太花哨的
💡 动画的奥义:好的动画是"用户感觉不到动画"——切换很流畅,但用户不会特意注意到"哦,这里有个动画"。如果用户注意到动画了,说明动画要么太炫酷、要么太慢、要么太奇怪。动画是服务于体验的,不是用来炫技的。
步骤3:动态 Tab——根据条件显示/隐藏
「民族图鉴」有个特殊需求:基础模式下,只显示 3 个 Tab(首页、百科、我的),地图和测验要隐藏。
怎么实现?用 if 条件渲染就行!
// pages/Index.ets
@StorageLink('currentContentMode') contentMode: string = 'full';
get isBasicMode(): boolean {
return this.contentMode === 'basic';
}
Tabs({ barPosition: BarPosition.End, index: this.currentIndex }) {
// 首页(始终显示)
TabContent() {
this.buildHomePage()
}
.tabBar(this.tabBuilder(this.tabs[0].title, this.tabs[0].icon, 0))
// 百科(始终显示)
TabContent() {
EthnicListPage()
}
.tabBar(this.tabBuilder(this.tabs[1].title, this.tabs[1].icon, 1))
// 地图(仅全量模式显示)
if (!this.isBasicMode) {
TabContent() {
MapPage()
}
.tabBar(this.tabBuilder(this.tabs[2].title, this.tabs[2].icon, 2))
}
// 测验(仅全量模式显示)
if (!this.isBasicMode) {
TabContent() {
QuizPage()
}
.tabBuilder(this.tabBuilder(this.tabs[3].title, this.tabs[3].icon, 3))
}
// 我的(始终显示,但索引会变!)
TabContent() {
ProfilePage()
}
.tabBar(
this.tabBuilder(
this.tabs[4].title,
this.tabs[4].icon,
this.isBasicMode ? 2 : 4 // ⚠️ 注意这里!基础模式下"我的"是第2个,全量模式下是第4个
)
)
}
.onChange((index: number) => {
this.currentIndex = index;
})
注意那个坑!——"我的"Tab 的索引在不同模式下是不一样的:
- 全量模式(5个Tab):我的是第 4 个(索引 4)
- 基础模式(3个Tab):我的是第 2 个(索引 2)
所以 tabBuilder 的第三个参数 targetIndex 不能写死 4,要根据模式动态计算:this.isBasicMode ? 2 : 4。
还有一个问题:如果当前在第 3 个 Tab(测验),切换到基础模式,会怎么样?
测验 Tab 消失了,currentIndex = 3 就越界了!怎么办?
边界检查:
// 模式变化时,检查当前索引是否越界
private checkTabBounds(): void {
const maxIndex = this.isBasicMode ? 1 : 4;
if (this.currentIndex > maxIndex) {
this.currentIndex = 0; // 越界了就回到首页
}
}
aboutToAppear(): void {
this.checkTabBounds(); // 页面出现时检查一次
// ...
}
💡 动态 Tab 的注意事项:
- 索引可能会变,不要写死
- 模式切换时要做边界检查,防止越界
- 尽量保持前几个 Tab 不变(比如首页永远是第一个),用户习惯了位置,变来变去会困惑
步骤4:Tabs 性能优化——懒加载与缓存
4.1 Tabs 默认会预加载吗?
你可能会想:我有 5 个 Tab,一打开首页,5 个页面的组件都会创建吗?那岂不是很慢?
答案是:不会,Tabs 是懒加载的——当前显示的 Tab 会创建,其他 Tab 不创建,等你切过去的时候才创建。
但切过一次之后,那个 Tab 的组件就会保留在内存里,再切回去不会重新创建。
第一次打开:
Tab 0(首页)→ 创建 ✅
Tab 1(百科)→ 未创建 ❌
Tab 2(地图)→ 未创建 ❌
...
切到 Tab 1(百科):
Tab 0 → 保留 ✅
Tab 1 → 创建 ✅
Tab 2 → 未创建 ❌
...
再切回 Tab 0(首页):
Tab 0 → 已经有了,不重建 ✅
Tab 1 → 保留 ✅
...
这样设计的好处是:
- 首次加载快:只创建当前显示的 Tab
- 切换流畅:切过的 Tab 都在内存里,秒切
4.2 手动控制缓存策略
如果你的 Tab 页面特别多、每个又特别重,都放在内存里可能占用太大,可以考虑手动控制。
但一般情况下,Tabs 的默认行为就挺好的,不用折腾。3-5 个 Tab 而已,内存占用很有限。
什么时候需要手动控制缓存?
| 场景 | 是否需要手动控制 | 建议 |
|---|---|---|
| 3-5 个普通页面 | 不需要 | 默认行为就很好 |
| 5-8 个普通页面 | 不需要 | 内存也够用 |
| 3 个很重的页面(每个都有大量图片/视频) | 可能需要 | 考虑不常用的页面销毁重建 |
| 10 个以上 Tab | 需要 | 建议重新设计信息架构 |
大多数应用 3-5 个 Tab,默认的缓存策略就够用了。真到了需要手动控制缓存的地步,可能更应该反思一下 Tab 设计是不是合理。
4.3 KeepAlive——页面保活机制
在 Web 开发里,有个概念叫 KeepAlive——把页面缓存起来,下次进入不用重新渲染。鸿蒙 Tabs 其实默认就有类似的机制:切过的 Tab 保留在内存里,再切回去不重建。
Tabs 的默认行为 = 自动 KeepAlive
- 当前显示的 Tab:活跃状态
- 切过的 Tab:保活状态(在内存里,隐藏了)
- 没切过的 Tab:未创建
KeepAlive 的好处:
- 切换快:秒切,不用等加载
- 状态保留:滚动位置、表单内容、用户操作都保留
- 用户体验好:用户切回来还是上次离开的样子
KeepAlive 的代价:
- 占用内存:每个保活的 Tab 都占内存
- 后台任务:如果 Tab 里有定时器、动画等,切走了还在跑(需要手动暂停)
💡 KeepAlive 和内存的权衡:
对于 3-5 个普通页面,KeepAlive 的好处远大于代价。
对于很重的页面(如视频页、3D 页面),可能需要权衡——是保活占内存,还是每次重建费时间。
「民族图鉴」的 Tab 都是普通的内容页,5 个 Tab 全保活也占不了多少内存,所以用默认策略就好。
4.4 预加载——让切换更丝滑
虽然 Tabs 是懒加载的,但我们可以在空闲的时候,提前把下一个可能用到的 Tab 预加载出来。这样用户切换的时候,就不用等创建了。
预加载的思路:
- 用户在首页的时候,悄悄创建百科页面
- 用户在百科的时候,悄悄创建地图页面
- 以此类推…
什么时候预加载?
| 时机 | 说明 | 推荐度 |
|---|---|---|
| 首页加载完成后 | 首页渲染完了,空闲时间预加载 | ⭐⭐⭐⭐ |
| 用户停留某 Tab 超过 N 秒 | 用户可能不会马上切走,可以预加载 | ⭐⭐⭐ |
| 应用启动后就全加载 | 启动慢,但之后切换都快 | ⭐⭐ 不推荐 |
预加载的实现思路:
// 用 if + opacity: 0 的方式预加载
// 组件会创建,但用户看不到,不影响当前页面
@State preloadTab1: boolean = false;
aboutToAppear(): void {
// 首页加载完,100ms 后预加载下一个 Tab
setTimeout(() => {
this.preloadTab1 = true;
}, 100);
}
Tabs() {
TabContent() {
HomePage()
}
TabContent() {
if (this.preloadTab1) {
EthnicListPage()
.opacity(0) // 预加载的时候透明,用户看不到
// 注意:这样写只是思路,实际Tabs里不能这么操作
}
}
}
注意:上面的代码只是思路演示,实际上 Tabs 组件自己会管理懒加载,我们不用手动预加载。鸿蒙 Tabs 的性能已经很好了,3-5 个 Tab 的创建很快,用户几乎感知不到。除非你的 Tab 页面特别重,否则不用搞预加载。
4.5 Tab 页面的生命周期
每个 Tab 页面(TabContent 里的组件)有自己的生命周期:
- 第一次显示时:
aboutToAppear→build→onPageShow - 切走时:
onPageHide(组件不会销毁,还在内存里) - 切回来时:
onPageShow(直接显示,不用重新 build) - 页面彻底销毁时:
aboutToDisappear
💡 划重点:Tab 切走后,组件不会销毁,只是隐藏了。所以如果你有定时器、事件监听等,要在
onPageHide里暂停,在onPageShow里恢复。别等aboutToDisappear,那时候用户可能已经切走很久了。
步骤5:Tabs 原理深入——它是怎么工作的?
你有没有好奇过,Tabs 底层是怎么实现的?为什么切换那么流畅?
让我们从架构层面扒一扒:
5.1 Tabs 的本质
Tabs 本质上是一个多页面容器,它管理着多个子页面,同一时间只显示一个,其他的隐藏。
┌─────────────────────────┐
│ Tabs 容器 │
│ │
│ ┌───────────────────┐ │
│ │ Tab 0(显示中) │ │ ← 当前显示
│ └───────────────────┘ │
│ ┌───────────────────┐ │
│ │ Tab 1(隐藏) │ │ ← 已创建,隐藏
│ └───────────────────┘ │
│ ┌───────────────────┐ │
│ │ Tab 2(未创建) │ │ ← 还没创建
│ └───────────────────�┘ │
│ │
│ ─── TabBar ─── │
│ [0] [1] [2] [3] [4] │
└─────────────────────────┘
5.2 为什么切换流畅?
Tab 切换的时候,不是重新创建页面,而是把已经创建好的页面从"隐藏"变成"显示"。
就像一叠纸,你把上面的一张抽走,下面那张露出来——下面那张本来就在那,不是刚画的。所以切换很快。
5.3 与页面路由的区别
很多人会搞混:Tabs 切换和 router 跳转有什么区别?
| 对比项 | Tabs 切换 | Router 跳转 |
|---|---|---|
| 页面关系 | 平级,兄弟页面 | 层级,父子页面 |
| 生命周期 | 切走不销毁,只是隐藏 | 跳走可能销毁(看 push 还是 replace) |
| 返回键 | 不影响,还在同一个页面 | 返回上一页 |
| 适用场景 | 同一层级的主要功能模块 | 进入详情、进入子页面 |
一句话总结:
- Tabs 是同一层的,就像书架上并排的几本书,你想看哪本抽哪本
- Router 是一层一层的,就像翻书,从目录翻到第一章,再翻到第一节
⚠️ 常见问题与解决方案
问题1:Tab 切换时页面重新加载,状态丢失
现象:
在 Tab 0 的列表里滚到了中间,切到 Tab 1 再切回来,又回到顶部了。或者表单填了一半,切走再切回来,内容没了。
原因分析:
| 可能原因 | 说明 |
|---|---|
| 用了 if/else 手动切换 Tab | 每次切换都销毁重建,当然丢状态 |
| 数据在 aboutToAppear 里重新加载 | 每次显示都重新拉数据,把旧数据覆盖了 |
| 组件 key 变了 | key 变了会导致组件重建 |
解决方案:
1. 用 Tabs 组件,不要自己用 if 写
// ❌ 不要这样写!每次切换都会销毁重建
if (this.currentTab === 0) {
HomePage()
} else if (this.currentTab === 1) {
ProfilePage()
}
// ✅ 用 Tabs 组件,框架帮你管理缓存
Tabs() {
TabContent() { HomePage() }
TabContent() { ProfilePage() }
}
2. 数据加载放在合适的时机
// ❌ 每次 onPageShow 都重新加载,切回来就刷新
onPageShow(): void {
this.loadData(); // 每次显示都重新加载
}
// ✅ 只在 aboutToAppear 加载一次,后续不刷新
aboutToAppear(): void {
this.loadData(); // 只加载一次
}
// ✅ 或者需要刷新的话,用事件通知,不要每次都刷
// 比如详情页修改了数据,回来才刷新
💡 什么时候需要刷新? 要看业务场景。比如"我的收藏"页面,用户在详情页收藏了一个民族,切回来要能看到新收藏的,这时候就需要刷新。但一般的列表页,切回来就刷新反而不好——用户滚到一半,切走看了个消息,回来又回到顶部了,很烦。
问题2:TabBar 高度不对,或者内容被 TabBar 挡住了
现象:
页面底部的内容被 TabBar 挡住了一部分,看不到。
原因:
Tabs 的内容区是占满整个高度的,包括 TabBar 所在的区域。如果你的页面底部有内容,可能会被 TabBar 盖住。
解决方案:
方案1:给内容底部加 padding(推荐)
在页面内容的底部,加一个和 TabBar 差不多高的 padding:
Scroll() {
Column() {
// ... 内容
}
.padding({ bottom: 80 }) // 底部留空,避免被 TabBar 挡住
}
80vp 是比较保险的值,TabBar 高度一般是 56vp,留点余量。
方案2:用 safeArea 安全区域适配
// 考虑底部安全区域(比如 iPhone 的底部横条)
.padding({ bottom: 56 + this.safeAreaBottom })
💡 「民族图鉴」的做法:首页的 Scroll 底部加了
padding({ bottom: $r('app.float.spacing_xxl') }),留了足够的空间,不会被 TabBar 挡住。简单有效。
问题3:Tab 太多,TabBar 装不下
现象:
有 6、7 个甚至更多 Tab,底部 TabBar 挤不下,文字换行或者图标叠在一起。
原因:
底部 TabBar 是为 3-5 个 Tab 设计的,太多了体验不好。
解决方案:
方案1:精简 Tab 数量(推荐)
把不重要的功能放到"我的"页面或者侧边菜单里。底部 Tab 只保留最核心的 3-5 个功能。
这不是技术问题,是产品设计问题。不要试图用技术手段解决产品问题。
方案2:用顶部 Tabs + 横向滚动
如果确实有很多分类,就把 TabBar 放顶部,做成可以横向滚动的:
Tabs({ barPosition: BarPosition.Start }) {
// ... 很多 Tab
}
.barMode(BarMode.Scrollable) // 可滚动模式
顶部可滚动 Tabs 适合分类多的场景(比如新闻 App 的各个频道)。
方案3:用 Grid 宫格导航
如果是工具类应用,有几十个功能,用 Tab 根本不现实。做成宫格布局(Grid),一屏放很多入口。
💡 底部 TabBar 的黄金法则:3-5 个,再多就该反思产品设计了。用户的记忆负担是有限的,太多 Tab 记不住哪个是哪个。微信也才 4 个 Tab 呢。
问题4:点击 Tab 没反应,或者不触发 onChange
现象:
点 TabBar 没反应,Tab 不切换,或者 onChange 不触发。
原因排查清单:
| 检查项 | 可能问题 | 怎么确认 |
|---|---|---|
| index 有没有绑定 | 没绑 @State 变量,状态没更新 | 看 Tabs 的 index 属性 |
| tabBar 点击区域 | 自定义 TabBar 太小,点不到 | 给每个 Tab 加背景色,看点击区域多大 |
| 是不是有其他组件盖在上面 | 某个组件 position: absolute 挡住了 | 用 DevEco 的布局检查器看看层级 |
| 手势冲突 | 有 Swiper 或其他手势组件抢了事件 | 去掉其他组件试试 |
最常见的坑:自定义 TabBar 高度太小
// ❌ 高度太小,点不到
@Builder
tabBuilder() {
Column() {
Text('🏠').fontSize(20)
Text('首页').fontSize(10)
}
// 没设高度!高度由内容撑开,可能只有 30vp,很难点中
}
// ✅ 明确设置高度,至少 44vp(移动端最小点击区域)
@Builder
tabBuilder() {
Column({ space: 4 }) {
Text('🏠').fontSize(22)
Text('首页').fontSize(10)
}
.width('100%')
.height(56) // 明确高度
.justifyContent(FlexAlign.Center)
}
移动端有个"44dp 法则"——触控目标的最小尺寸是 44×44dp(vp)。太小了手指点不准,用户会抓狂。TabBar 每个按钮的高度一般 56vp,宽度平分屏幕,肯定够大。
问题5:Tab 切换动画太生硬,想自定义
现象:
默认的 Tab 切换效果(左右滑动)不满意,想改成淡入淡出,或者不要动画。
解决方案:
设置动画时长为 0,取消动画
Tabs() {
// ...
}
.animationDuration(0) // 动画时长 0,就是没有动画
或者用自定义的方式
如果你想要完全自定义的切换效果,可以不用 Tabs 组件,自己用 if + 动画实现:
@State currentIndex: number = 0;
@State showContent: boolean = true;
private switchTab(index: number): void {
if (index === this.currentIndex) return;
// 先淡出
animateTo({ duration: 200 }, () => {
this.showContent = false;
});
// 再淡入新内容
setTimeout(() => {
this.currentIndex = index;
animateTo({ duration: 200 }, () => {
this.showContent = true;
});
}, 200);
}
build() {
Column() {
// 内容区
if (this.currentIndex === 0) {
HomePage().opacity(this.showContent ? 1 : 0)
} else if (this.currentIndex === 1) {
ProfilePage().opacity(this.showContent ? 1 : 0)
}
// 自定义 TabBar
Row() {
// ...
}
}
.width('100%')
.height('100%')
}
但说实话,默认的左右滑动效果就挺好的,符合用户习惯。除非有特殊的设计需求,否则不建议自己折腾。
📝 本章小结
核心知识点
本文深入讲解了 Tabs 组件与自定义 TabBar 的实现:
1. Tabs 基础
- 结构:TabContent(内容) + TabBar(标签栏)
- barPosition:Start(顶部)/ End(底部)
- index:当前选中的 Tab 索引
- onChange:切换回调
2. 自定义 TabBar
- 用
@Builder装饰器,可以完全自定义样式 - 经典样式:图标 + 文字,选中态变色
- 图标可以用 emoji(Unicode),简单方便
- 每个 Tab 高度 56vp,保证点击区域够大
3. 动态 Tab
- 用 if 条件渲染,可以根据状态显示/隐藏 Tab
- ⚠️ 注意索引会变,不要写死
- 模式切换时要做边界检查,防止越界
- 尽量保持前几个 Tab 位置不变,符合用户习惯
4. 性能与生命周期
- Tabs 是懒加载的:当前显示的创建,其他的切过去才创建
- 切过的 Tab 保留在内存里,再切回去不重建
- Tab 页面的生命周期:aboutToAppear → onPageShow → onPageHide → aboutToDisappear
- 切走不销毁,只是隐藏。定时器、事件监听要在 onPageHide 暂停
5. Tabs 原理
- 本质:多页面容器,同一时间显示一个
- 切换流畅的原因:已创建的页面只是隐藏/显示,不重建
- 和 Router 的区别:Tabs 是平级的,Router 是层级的
最佳实践总结
✅ Tab 数量控制在 3-5 个
// ✅ 3-5 个最合适
tabs = ['首页', '百科', '我的'] // 3个,好
tabs = ['首页', '百科', '地图', '测验', '我的'] // 5个,上限
// ❌ 太多了
tabs = ['首页', '发现', '消息', '视频', '直播', '购物', '我的'] // 7个,太多了
✅ 自定义 TabBar 高度至少 56vp
@Builder
tabBuilder() {
Column() { /* 图标 + 文字 */ }
.width('100%')
.height(56) // 保证点击区域
.justifyContent(FlexAlign.Center)
}
✅ 选中态颜色用主色调,未选中用灰色
.fontColor(
this.currentIndex === targetIndex
? $r('app.color.primary_color') // 选中:主色
: $r('app.color.text_hint') // 未选中:灰色
)
✅ 动态 Tab 要做边界检查
private checkTabBounds(): void {
const maxIndex = this.isBasicMode ? 1 : 4;
if (this.currentIndex > maxIndex) {
this.currentIndex = 0;
}
}
✅ 不要用 if/else 手动写 Tab 切换
// ❌ 不要自己写,状态丢失、性能差
if (this.index === 0) { HomePage() }
else if (this.index === 1) { ProfilePage() }
// ✅ 用 Tabs 组件,框架帮你做好缓存和性能
Tabs() {
TabContent() { HomePage() }
TabContent() { ProfilePage() }
}
下一篇预告
Tabs 框架搭好了,接下来该填首页的内容了。
下一篇(第13篇)我们将讲解首页搜索——搜索框组件与实时过滤:
- TextInput 组件的使用与事件处理
- 搜索逻辑的实现(模糊匹配、拼音搜索)
- 搜索结果高亮显示
- 搜索历史记录
搜索是用户找到内容的入口,也是首页最常用的功能之一。
🔗 相关链接
- 项目源码: GitCode 仓库
- Tabs 组件: 官方文档
- TabContent: 官方文档
- @Builder 装饰器: 官方文档
💡 提示:Tabs 是应用的骨架,设计好 Tab 架构是第一步,也是最重要的一步。Tab 怎么分、分几个、每个 Tab 放什么功能,这些产品设计的决策,比技术实现重要得多。技术上实现 Tabs 很简单,但设计出合理的 Tab 架构,需要对用户需求有深刻的理解。做技术的同学,也不妨多思考思考产品层面的问题——为什么是这 5 个 Tab?为什么是这个顺序?想明白了,你写出来的代码才不只是"能跑",而是"好用"。
更多推荐


所有评论(0)