鸿蒙ArkUI实战:底部Tab导航与消息角标
本文介绍了如何使用ArkUI的Tabs组件构建一个完整的移动应用底部导航页面。主要内容包括: 实现四个Tab页面的布局设计: 首页:欢迎语+数据卡片+时间线 发现:分类网格+推荐列表 消息:通知列表(含未读角标) 我的:用户信息+菜单 关键交互功能实现: Tab切换与活跃态样式管理 消息未读数字角标动态显示(支持99+) 点击消息标记已读功能 退出登录确认弹窗 技术要点: 使用Tabs+TabCo
底部 Tab 导航是移动 App 最常见的导航模式。本文用 ArkUI 的 Tabs 组件构建一个完整的四 Tab 页面,覆盖自定义 tabBar、消息角标、Tab 切换状态管理,以及各 Tab 内不同内容布局的设计。
一、我们要做什么
一个完整的四 Tab 底部导航页面:
- 首页 — 欢迎语 + 数据统计卡片 + 最近动态时间线
- 发现 — 分类 Grid + 热门推荐列表
- 消息 — 消息通知列表,未读消息红点 + 红色角标,点击标记已读
- 我的 — 用户信息卡片 + 菜单列表 + 退出登录
四个交互点:
- Tab 切换 — 点击底部四个 Tab,对应的内容切换,当前 Tab 高亮蓝色,其余灰色
- 消息角标 — 消息 Tab 上显示未读数量红色角标,超过 99 显示"99+",全部已读后角标消失
- 标记已读 — 点击未读消息 → 标记已读 → 角标减 1;点击"全部已读" → 所有消息变已读 → 角标消失
- 退出登录 — "我的"Tab 底部退出按钮,弹窗确认后 Toast 提示
二、Tabs 组件基础
2.1 核心结构
Tabs({ index: $$this.currentTab, controller: this.tabsController }) {
TabContent() {
// Tab 1 的内容
}
.tabBar('首页')
TabContent() {
// Tab 2 的内容
}
.tabBar('发现')
TabContent() {
// Tab 3 的内容
}
.tabBar(this.messageTabLabel()) // 自定义 tabBar(含角标)
TabContent() {
// Tab 4 的内容
}
.tabBar('我的')
}
.barPosition(BarPosition.End) // 底部导航
.barHeight(56) // 导航栏高度
.barMode(BarMode.Fixed) // 固定模式,不滚动
.onChange((index: number) => {
this.currentTab = index; // 同步当前 Tab 索引
})
Tabs 是容器,TabContent 是每个 Tab 页的内容,.tabBar() 设置底部标签。barPosition: BarPosition.End 把 Tab 栏放在底部——如果省略,默认在顶部(适合顶部 Tab 切换的场景,如新闻分类)。
2.2 关键属性
| 属性 | 作用 |
|---|---|
index: $$this.currentTab |
双向绑定当前选中 Tab 的索引,修改 currentTab 可编程式切换 Tab |
controller: this.tabsController |
TabsController 实例,可调用 changeIndex(n) 方法切换 Tab |
barPosition |
BarPosition.Start(顶部,默认)/ BarPosition.End(底部) |
barMode |
BarMode.Fixed(均分宽度)/ BarMode.Scrollable(可左右滑动,适合 5+ 个 Tab) |
barHeight |
Tab 栏高度,默认 56vp |
onChange |
Tab 切换回调,参数是新 Tab 的索引 |
2.3 $$ 双向绑定与 onChange 的区别
$$this.currentTab 负责"状态 → UI"和"UI → 状态"的双向同步:点击 Tab → currentTab 自动更新;修改 currentTab → Tab 自动切换。
onChange 是额外的通知回调,用于执行切换后的副作用(如埋点、懒加载数据)。不能替代 $$——如果只写了 onChange 没写 $$,状态变量不会更新,其他依赖 currentTab 的逻辑(如角标高亮颜色)会失效。

三、交互点1:Tab 切换与活跃态样式
3.1 普通 Tab 标签
@Builder
tabLabel(text: string, index: number) {
Column() {
Text(text)
.fontSize(12)
.fontColor(this.currentTab === index ? AppColors.PRIMARY : AppColors.TEXT_TERTIARY)
.fontWeight(this.currentTab === index ? FontWeight.Medium : FontWeight.Regular)
}
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.width('100%')
.height('100%')
}
this.currentTab === index 是判断当前 Tab 是否活跃的唯一条件。currentTab 通过 $$ 双向绑定自动更新,通过 onChange 回调同步——两路都保证状态正确。
四个 Tab 的 tabBar 调用:
.tabBar(this.tabLabel('首页', 0))
.tabBar(this.tabLabel('发现', 1))
.tabBar(this.messageTabLabel()) // 特殊:带角标
.tabBar(this.tabLabel('我的', 3))
用 @Builder 而非直接传字符串,是因为字符串只能控制文字,不能控制颜色、字重。自定义 @Builder 可以访问 this.currentTab 状态,实现活跃/非活跃的样式切换。

四、交互点2:消息 Tab 的红色角标
get unreadCount(): number {
return this.messages.filter((m: TabMessage) => !m.isRead).length;
}
unreadCount 是计算属性(getter),不是 @State。它每次被读取时重新计算,基于 this.messages 数组(@State)的状态。当消息的 isRead 变化触发 @State 更新时,UI 重新渲染,getter 自动返回新的未读数。
4.1 自定义消息 Tab 标签
@Builder
messageTabLabel() {
Stack() {
Text('消息')
.fontSize(12)
.fontColor(this.currentTab === 2 ? AppColors.PRIMARY : AppColors.TEXT_TERTIARY)
.fontWeight(this.currentTab === 2 ? FontWeight.Medium : FontWeight.Regular)
if (this.unreadCount > 0) {
Text(this.unreadCount > 99 ? '99+' : `${this.unreadCount}`)
.fontSize(10)
.fontColor(Color.White)
.backgroundColor(AppColors.ERROR)
.borderRadius(8)
.textAlign(TextAlign.Center)
.padding({ left: 3, right: 3, top: 1, bottom: 1 })
.offset({ x: 20, y: -8 })
}
}
.alignContent(Alignment.Center)
.width('100%')
.height('100%')
}
用 Stack 把"消息"文字和角标叠在一起。角标用 offset({ x: 20, y: -8 }) 偏移到文字右上角——x 正值向右偏移,y 负值向上偏移。
角标文本的逻辑:
unreadCount === 0→ 不渲染角标(条件渲染直接跳过)1 ≤ unreadCount ≤ 99→ 显示具体数字unreadCount > 99→ 显示"99+"(防止角标过宽影响布局)
为什么用 Text 而不是 Badge 属性?ArkUI 的 .badge() 属性在某些组件上支持,但定制能力有限——角标颜色、字体大小、位置都是固定的。用 Text + Stack 完全自主控制,任何组件都能用,不需要查 API 文档看"这个组件支不支持 badge"。

五、交互点3:消息标记已读
5.1 单条标记已读
private markAsRead(id: number): void {
const idx = this.messages.findIndex((m: TabMessage) => m.id === id);
if (idx >= 0 && !this.messages[idx].isRead) {
this.messages[idx].isRead = true;
this.messages = [...this.messages];
promptAction.showToast({ message: '已标为已读', duration: 1200 });
}
}
注意 !this.messages[idx].isRead 的守卫——如果消息已经已读,重复点击不会再弹 Toast,也不会触发布局更新。
整个消息 Row 的 onClick 绑定 markAsRead,点击任意位置都是"标记已读"——不需要额外按钮。在真实 App 中,点消息通常是跳转到详情页,但 Demo 简化为标记已读。
5.2 全部已读
private markAllRead(): void {
let hasUnread = false;
this.messages.forEach((m: TabMessage) => {
if (!m.isRead) {
m.isRead = true;
hasUnread = true;
}
});
if (hasUnread) {
this.messages = [...this.messages];
promptAction.showToast({ message: '全部已读', duration: 1200 });
}
}
hasUnread 标记避免了"没有未读消息时仍触发 @State 更新"的无效渲染。forEach 直接修改数组元素的属性(m.isRead = true),但只有最后的 [...this.messages] 才触发 UI 重新渲染——单个属性修改不会触发 @State。
5.3 消息列表 UI
Row() {
Column() {
if (!msg.isRead) {
Row()
.width(8).height(8)
.borderRadius(4)
.backgroundColor(AppColors.ERROR)
.margin({ right: Spacing.MD, top: 2 })
} else {
Row()
.width(8).height(8)
.margin({ right: Spacing.MD, top: 2 })
}
Image($r('sys.symbol.message'))
.width(36).height(36)
.fillColor(AppColors.TEXT_DISABLED)
}
Column() {
Text(msg.title)
.fontSize(FontSize.BODY)
.fontColor(msg.isRead ? AppColors.TEXT_SECONDARY : AppColors.TEXT_PRIMARY)
.fontWeight(msg.isRead ? FontWeight.Regular : FontWeight.Medium)
.maxLines(1)
Text(msg.content)
.fontSize(FontSize.CAPTION)
.fontColor(AppColors.TEXT_TERTIARY)
.maxLines(1)
.margin({ top: 2 })
}
.layoutWeight(1)
.margin({ left: Spacing.MD })
Text(msg.time)
.fontSize(FontSize.CAPTION)
.fontColor(AppColors.TEXT_DISABLED)
.margin({ left: Spacing.SM })
}
.width('100%')
.padding({ left: Spacing.LG, right: Spacing.LG, top: Spacing.MD, bottom: Spacing.MD })
.backgroundColor(Color.White)
.margin({ bottom: 1 })
.onClick(() => this.markAsRead(msg.id))
未读 vs 已读的视觉层次:
- 未读 — 红色圆点 + 黑色粗体标题 + 灰色正文
- 已读 — 透明占位点(保持布局对齐)+ 灰色常规标题 + 灰色正文
关键技巧:透明占位点。 很多人在这里犯的错误是:已读消息不要红点 → if (!msg.isRead) 不渲染红点 → 已读和未读消息的文本起始位置不对齐。解决方案是渲染一个同样尺寸的透明 Row 占位——宽度、高度、margin 完全一致,只是没有背景色。这样所有消息的文本左边对齐,阅读体验一致。

六、四个 Tab 的内容设计
6.1 首页 — 统计卡片 + 时间线
@Builder
HomeTab() {
Scroll() {
Column() {
Text('欢迎回来')
.fontSize(FontSize.HEADLINE)
.fontWeight(FontWeight.Bold)
...
Text('今天也是元气满满的一天')
.fontSize(FontSize.BODY)
.fontColor(AppColors.TEXT_TERTIARY)
...
// 4 个统计卡片
Row() {
this.statCard('文章', '48', '#1677FF')
this.statCard('收藏', '126', '#52C41A')
this.statCard('关注', '23', '#FAAD14')
this.statCard('粉丝', '89', '#722ED1')
}
.justifyContent(FlexAlign.SpaceBetween)
// 时间线
Text('最近动态')...
this.activityItem('你发布了一篇新文章', '...', '2小时前')
this.activityItem('你的文章被收藏', '...', '5小时前')
...
}
}
}
统计卡片用 @Builder statCard(label, value, color) 封装——4 个卡片调用同一个 Builder,不同 label/value/color。FlexAlign.SpaceBetween 让 4 个卡片均分宽度+等距。
时间线用了自定义的"圆点+竖线"布局:
Column() {
Row().width(8).height(8).borderRadius(4).backgroundColor(AppColors.PRIMARY)
Row().width(1).layoutWeight(1).backgroundColor(AppColors.BORDER)
}
最顶部的圆点永远是蓝色实心,竖线用 layoutWeight(1) 撑满剩余高度。对于最后一条动态,竖线可以省略(但 Demo 中简单处理,每条都画竖线——实际 App 中可以通过 index === last 条件渲染跳过)。
6.2 发现 — 分类 Grid + 热门推荐
分类用 4 列 Grid:
Grid() {
ForEach([...8 个 DiscoverItem...], (item: DiscoverItem) => {
GridItem() {
Column() {
Text(item.icon).fontSize(28)
Text(item.label).fontSize(FontSize.CAPTION)
}
.backgroundColor(item.color) // 每个格子不同底色
}
})
}
.columnsTemplate('1fr 1fr 1fr 1fr')
.rowsGap(Spacing.SM)
.columnsGap(Spacing.SM)
columnsTemplate('1fr 1fr 1fr 1fr') 表示 4 列均分宽度——比 columnsGap 和固定 columnWidth 更灵活,适配不同屏幕。
8 个分类,每行 4 个,刚好 2 行,不需要滚动。
6.3 消息 — 通知列表(第五节已详述)
6.4 我的 — 用户卡片 + 菜单
用户信息卡片:左侧 64×64 蓝色圆形头像(首字符"张"),右侧名字+简介。
菜单用三重分组:
[我的收藏, 浏览记录, 我的文章] ← 白色卡片,间距 SM
[设置, 帮助与反馈, 关于] ← 白色卡片,间距 SM
退出登录 ← 红色文字,独立白色卡片
组合复用 @Builder menuItem(label, icon) —— 左侧 Unicode 符号 + 文字 + 右侧箭头 ›。点击任意菜单弹出"功能开发中"Toast——因为是 Demo,不做真实页面跳转。
退出登录复用 promptAction.showDialog 确认模式,result.index === 1 判断用户点击了"确定"。
七、数据模型
class TabMessage {
id: number;
title: string;
content: string;
time: string;
isRead: boolean;
}
class DiscoverItem {
icon: string;
label: string;
color: string;
}
class HotArticle {
title: string;
reads: string;
}
所有数据模型定义在页面文件内——Demo 规模不需要单独 model 文件。
注意 HotArticle 和 DiscoverItem 的引入是为了绕过 ArkTS 的限制:对象字面量不能作为类型声明。写成 (item: { icon: string; label: string }) 会触发 arkts-no-obj-literals-as-types 编译错误,必须提前定义为命名类型。
八、页面结构总结
TabsPage (~370 行)
├── 数据层
│ ├── TabMessage / DiscoverItem / HotArticle 类
│ └── MOCK_MESSAGES 常量数组(8 条)
├── 状态层
│ ├── @State currentTab: number — 当前 Tab 索引
│ ├── @State messages: TabMessage[] — 消息列表(含已读状态)
│ ├── get unreadCount: number — 计算属性,未读消息数
│ └── tabsController: TabsController — Tab 控制器
├── 业务方法
│ ├── markAsRead(id) — 单条标记已读
│ └── markAllRead() — 全部已读
├── Tab 标签 Builder
│ ├── tabLabel(text, index) — 普通 Tab 标签(活跃/非活跃)
│ └── messageTabLabel() — 消息 Tab 标签(含角标)
└── Tab 内容 Builder
├── HomeTab() — 欢迎 + 统计卡片 + 时间线
├── DiscoverTab() — 分类 Grid + 热门推荐
├── MessageTab() — 消息列表 + 全部已读按钮
└── ProfileTab() — 用户卡片 + 菜单 + 退出
四个 Tab 的内容都是懒加载——只有当前显示的 TabContent 才会渲染。ArkUI 的 Tabs 默认行为是只渲染当前 Tab,切过去才实例化下一个。这意味着在 aboutToAppear 中不需要手动做懒加载逻辑。
九、常见面试题 / 踩坑点
9.1 barMode: Fixed vs Scrollable 什么时候用?
- Fixed — Tab 数量 ≤ 5 时使用。每个 Tab 均分宽度,所有 Tab 同时可见。
- Scrollable — Tab 数量 > 5 时使用。每个 Tab 根据内容自适应宽度,超出屏幕可左右滑动。
一个典型的错误:6 个 Tab 用了 Fixed → 每个 Tab 宽度约 60vp → 文字截断 → 用户体验差。改为 Scrollable 后每个 Tab 有足够空间。
9.2 为什么角标用 Stack+offset 而不是 position?
position 是绝对定位,坐标相对于父组件的左上角。offset 是相对偏移,在元素自身布局位置的基础上移动。
对于角标场景,需要让角标跟在"消息"文字右上方——但文字宽度不固定("消息"两个字 vs "通知"两个字),绝对定位需要计算文字宽度,而 offset 始终相对于元素当前位置偏移,更简单。
9.3 计算属性 vs @State —— get unreadCount 为什么不需要 @State?
unreadCount 的值完全由 messages 数组派生而来。当 messages 的 @State 触发 UI 重新渲染时,unreadCount 的 getter 被重新调用,自然得到最新值。
如果手动维护一个 @State unreadCount,每次改变消息已读状态时要同时更新两个状态——这违反了"单一数据源"原则,容易导致 unreadCount 和实际未读消息数不一致。
判断是否该用计算属性:如果值 B 可以直接从值 A 推导出来,B 就不该有独立的状态。B = compute(A) → getter。只在 B 有独立来源(用户输入、网络响应、定时器)时才需要 @State。
9.4 TabContent 内的内容需要各自独立的高度吗?
不需要。Tabs + TabContent 自动管理——当前激活的 TabContent 会获得父容器分配的空间。但需要注意:如果 TabContent 内的内容需要滚动,必须在 TabContent 内部用 Scroll 或 List,不能在 Tabs 外面套 Scroll。
9.5 $$this.currentTab 和 onChange 同时使用会冲突吗?
不会。$$ 负责双向同步(UI ↔ State),onChange 是额外的通知钩子。执行顺序:用户点击 Tab → currentTab 更新($$)→ onChange 回调执行。onChange 中可以安全地读取最新的 currentTab。
如果只用 onChange 不用 $$,状态不会更新——这会导致 tabLabel 中的 this.currentTab === index 判断永远在第一个 Tab 上生效。
9.6 内存管理:Tab 切换时前一个 Tab 会销毁吗?
默认行为是缓而不销:切换出视口的 TabContent 保持在内存中,DOM 节点不销毁,切换回去时不重新创建——直接显示。这意味着:
- 好处:切换 Tab 很快,不需要重新渲染
- 代价:4 个 Tab 的消息都占内存
如果某个 Tab 的数据很大(如长列表),可以用 if (this.currentTab === index) 条件渲染内部内容,让非活跃 Tab 只保留外壳不保留列表。
十、运行方式
代码位于 dev/entry/src/main/ets/pages/TabsPage.ets。
用 DevEco Studio 打开 dev/ 项目,首页点击"底部Tab导航 — 四Tab切换与消息角标"即可体验:
- 进入页面 → "首页"Tab 激活(蓝色),显示欢迎语 + 4 个统计卡片 + 时间线
- 点击"发现" → 切换到分类 Grid + 热门推荐
- 点击"消息" → 3 条未读消息(带红色圆点),Tab 上显示红色角标"3"
- 点击一条未读消息 → 红点消失 + Toast"已标为已读" + 角标减为"2"
- 点击"全部已读" → 所有消息变已读 + 角标消失
- 点击"我的" → 用户卡片 + 菜单 + 退出按钮
- 点击"退出登录" → 弹窗确认 → Toast"已退出登录"
十一、扩展方向
- 路由联动 Tab — 在"首页"Tab 点击某条动态 → router.pushUrl 跳转到详情页(跨 Tab 导航)
- Tab 懒加载数据 — 首次切换到某 Tab 时才发起网络请求,配合 loading 状态
- BottomNavigation + Navigation 组合 — 使用 HarmonyOS 的 Navigation 组件实现更完整的导航栈(Tab 内各页面独立路由栈)
- Tab 切换动画 — 自定义切 Tab 时的过渡动画(滑动进入/退出)
- 角标数字动画 — 未读数变化时加弹性动画(animateTo),数字从小到大弹入
- Tab 图标 — 替换纯文字标签为图标+文字的布局,活跃态图标不同颜色
- 中间凸起按钮 — 将第三个 Tab 设计为凸起的发布按钮(类似闲鱼/Instagram),用 margin 负值 + 大圆角实现
- Deep Link — 通过 URL Scheme 或推送通知直接跳转到指定 Tab 的指定页面
- Tab 顺序持久化 — 用 Preferences 保存用户上次使用的 Tab,下次启动恢复到该 Tab
- 消息推送集成 — 收到推送后实时增加未读计数(WebSocket + @State 更新)
更多推荐



所有评论(0)