在这里插入图片描述

📖 引言

启动页动画播完,用户进入应用,第一眼看到的是什么?是首页。

而几乎所有 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 个?

  1. 拇指热区有限:底部就那么宽,5 个 Tab 已经是极限了,再多每个 Tab 的宽度就太小了,点不准
  2. 记忆负担:人一次能记住的东西是 7±2 个,但导航 Tab 最好控制在 5 个以内,用户不用想就能找到
  3. 重要性法则:如果有 6、7 个 Tab,说明你把不重要的功能也放到底部了,该精简了

看看你手机上最常用的 App:微信 4 个、支付宝 5 个、抖音 5 个、小红书 5 个… 几乎没有超过 5 个的。这是行业共识。

原则二:图标 + 文字,不要只有图标或只有文字

样式 优点 缺点 适用场景
只有图标 简洁、节省空间 用户可能猜不出什么意思 用户量极大、人人都用的超级 App
只有文字 清晰易懂 不够直观、有点单调 工具类、文字属性强的应用
图标 + 文字 直观易懂,记忆成本低 占用空间稍大 绝大多数应用(推荐)

「民族图鉴」为什么选图标 + 文字?

  • 我们是文化类应用,用户群体年龄跨度大,光看图可能不知道什么意思
  • 文字能准确传达 Tab 的功能,降低用户的学习成本
  • 5 个 Tab,图标 + 文字也放得下,不会太挤

💡 什么时候可以只有图标? 如果你的 App 是像微信、抖音那样的国民级应用,用户已经形成肌肉记忆了,光看图就知道哪个是哪个,可以考虑只有图标。但大多数应用,老实上图标 + 文字吧。

原则三:选中态要明显,用户一眼知道自己在哪

用户切换 Tab 的时候,必须能立刻反馈——“我点了,切过去了”。

选中态的设计方式

方式 效果 推荐度
颜色变化(最常用) 选中变主色,未选中灰色 ⭐⭐⭐⭐⭐
图标变化(填充/线框) 选中是填充图标,未选中是线框图标 ⭐⭐⭐⭐
文字加粗 选中文字加粗 ⭐⭐⭐
大小变化 选中图标放大一点 ⭐⭐⭐
背景色变化 选中的 Tab 背景变个色 ⭐⭐
上移动画 选中的 Tab 稍微往上移一点 ⭐⭐

「民族图鉴」的选择:颜色变化——选中变主色调,未选中灰色。

为什么?

  1. 最简单直接,用户一眼就能看懂
  2. 实现成本最低,代码最简单
  3. 和整体设计风格一致,不花哨

💡 不要搞太复杂的选中态。底部导航是基础组件,不是炫技的地方。用户用了三个月之后,根本不会注意到选中态是什么样的——但如果没有明显的选中态,用户会困惑"我现在在哪?“。选中态的目标是"清晰”,不是"炫酷"。

原则四:点击区域够大,至少 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:首页、百科、我的。

为什么这么设计?

  1. 核心功能保留:首页(发现)、百科(内容)、我的(个人中心),这三个构成了一个内容型应用的最小闭环
  2. 特色功能隐藏:地图和测验是特色功能,但不是核心功能。内容模式下,用户更关注内容本身
  3. 降低认知负担:Tab 少了,界面更简洁,用户更容易上手
  4. 渐进式披露:先用简单的版本吸引用户,等用户有兴趣了,再解锁更多功能

这也是一种产品设计思路——MVP(最小可行产品)。先做核心功能,验证用户需求,再慢慢加功能。「民族图鉴」的基础模式,就相当于一个 MVP。


💡 需求分析

为什么需要 Tabs?

一个应用通常有好几个主要功能模块,怎么组织?

方案 优点 缺点 适用场景
列表菜单 简单直接,想加多少加多少 操作层级深,每次要回去选 设置页、工具类应用
侧边抽屉 节省空间,可放很多功能 不直观,用户不容易发现 内容型应用、侧边菜单
底部 TabBar 直观可见,一键切换,操作成本最低 Tab 数量有限(建议 3-5 个) 主流应用,核心功能导航

底部 TabBar 是目前移动端应用最主流的导航方式,因为:

  1. 拇指热区:手机屏幕底部是单手拇指最容易触达的区域
  2. 所见即所得:有哪些功能一目了然,不用猜
  3. 切换成本低:点一下就切过去了,不用返回重选

所以几乎所有你常用的 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)
}

实现要点

  1. 图标用 Unicode\u{1F3E0} 这种是 emoji 的 Unicode 编码,直接用 Text 组件就能显示,不用找图标字体或图片,简单方便
  2. 选中态判断this.currentIndex === targetIndex,相等就是选中状态,颜色用主色调;不相等就是未选中,用灰色
  3. 文字用资源$r('app.string.' + tabTitle),配合国际化,自动显示对应语言
  4. 统一尺寸:每个 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)
}

关键点

  1. Stack 把图标和 Badge 叠在一起
  2. Badge 用 Circle 组件画一个红色小圆点
  3. position 把小圆点定位到图标右上角
  4. 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 的默认动画,自己用透明度动画实现

思路是:

  1. 把 Tabs 的动画时长设为 0(取消默认动画)
  2. 给每个 TabContent 的内容加透明度动画
  3. 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 左右的时长。

为什么不搞花里胡哨的?

  1. 默认的左右滑动最自然,用户已经习惯了
  2. 实现简单,不容易出 bug
  3. 性能好,系统层面优化过
  4. 文化类应用走稳重路线,不搞太花哨的

💡 动画的奥义:好的动画是"用户感觉不到动画"——切换很流畅,但用户不会特意注意到"哦,这里有个动画"。如果用户注意到动画了,说明动画要么太炫酷、要么太慢、要么太奇怪。动画是服务于体验的,不是用来炫技的。


步骤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 的注意事项

  1. 索引可能会变,不要写死
  2. 模式切换时要做边界检查,防止越界
  3. 尽量保持前几个 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 里的组件)有自己的生命周期:

  • 第一次显示时:aboutToAppearbuildonPageShow
  • 切走时: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 组件的使用与事件处理
  • 搜索逻辑的实现(模糊匹配、拼音搜索)
  • 搜索结果高亮显示
  • 搜索历史记录

搜索是用户找到内容的入口,也是首页最常用的功能之一。


🔗 相关链接


💡 提示:Tabs 是应用的骨架,设计好 Tab 架构是第一步,也是最重要的一步。Tab 怎么分、分几个、每个 Tab 放什么功能,这些产品设计的决策,比技术实现重要得多。技术上实现 Tabs 很简单,但设计出合理的 Tab 架构,需要对用户需求有深刻的理解。做技术的同学,也不妨多思考思考产品层面的问题——为什么是这 5 个 Tab?为什么是这个顺序?想明白了,你写出来的代码才不只是"能跑",而是"好用"。

Logo

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

更多推荐