底部 Tab 导航是移动 App 最常见的导航模式。本文用 ArkUI 的 Tabs 组件构建一个完整的四 Tab 页面,覆盖自定义 tabBar、消息角标、Tab 切换状态管理,以及各 Tab 内不同内容布局的设计。


一、我们要做什么

一个完整的四 Tab 底部导航页面:

  1. 首页 — 欢迎语 + 数据统计卡片 + 最近动态时间线
  2. 发现 — 分类 Grid + 热门推荐列表
  3. 消息 — 消息通知列表,未读消息红点 + 红色角标,点击标记已读
  4. 我的 — 用户信息卡片 + 菜单列表 + 退出登录

四个交互点:

  1. Tab 切换 — 点击底部四个 Tab,对应的内容切换,当前 Tab 高亮蓝色,其余灰色
  2. 消息角标 — 消息 Tab 上显示未读数量红色角标,超过 99 显示"99+",全部已读后角标消失
  3. 标记已读 — 点击未读消息 → 标记已读 → 角标减 1;点击"全部已读" → 所有消息变已读 → 角标消失
  4. 退出登录 — "我的"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 文件。

注意 HotArticleDiscoverItem 的引入是为了绕过 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 内部用 ScrollList,不能在 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切换与消息角标"即可体验:

  1. 进入页面 → "首页"Tab 激活(蓝色),显示欢迎语 + 4 个统计卡片 + 时间线
  2. 点击"发现" → 切换到分类 Grid + 热门推荐
  3. 点击"消息" → 3 条未读消息(带红色圆点),Tab 上显示红色角标"3"
  4. 点击一条未读消息 → 红点消失 + Toast"已标为已读" + 角标减为"2"
  5. 点击"全部已读" → 所有消息变已读 + 角标消失
  6. 点击"我的" → 用户卡片 + 菜单 + 退出按钮
  7. 点击"退出登录" → 弹窗确认 → 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 更新)
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐