Tabs 选项卡切换

本文配套演示工程见同目录 ohos/,包含五个可运行的 ArkTS 原生示例,覆盖基础分页、TabBar 样式定制、控制器切换、事件状态、动态增删。所有示例均在 DevEco Studio + 模拟器验证通过。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

1. 引言:把世界切成几叠

人处理信息有个朴素习惯:分类。设置里分"通用/显示/隐私",电商里分"首页/分类/购物车/我的",新闻里分"推荐/热点/本地"。把一堆内容按主题切成几叠,每叠一个入口,要用哪叠点哪叠——这种交互范式,在移动端几乎成了标配,而承载它的组件,就是 Tabs

在鸿蒙(HarmonyOS)的 ArkUI 声明式框架里,Tabs 是一个"组合型"容器:它自身不画内容,而是把若干 TabContent(每一叠)和一组标签栏(tabBar)组合起来。用户点标签或左右滑,内容区在几叠之间切换。理解 Tabs,关键在于它解决的是"同一屏内切换不同上下文"的问题——和 Router 翻页、Navigation 进栈不同,Tabs 的所有页都在同一级,切换快、上下文不丢。

1.1 Tabs 在导航家族里的位置

ArkUI 的导航/切换类组件不止 Tabs,把它和兄弟摆在一起才清楚:

组件 核心语义 典型场景
Tabs 同屏内多上下文切换 首页/我的、分段筛选
Router 整页跳转、可返回 详情页、登录页
Navigation 栈式导航、带标题栏 主从结构、设置树
Swiper 整屏翻页、强调"一页页" 引导页、Banner
Grid 网格排布(非切换) 九宫格入口

可以看到,Tabs 的邻居 Swiper 最容易和它混。区别在意图:Swiper 强调"一页一页翻、看完即过"(引导页),Tabs 强调"几个并列入口、随时来回切"(主底栏)。如果你要的是"底部四个 tab 来回逛",用 Tabs;如果是"首次打开的三页引导",用 Swiper。意图定,选型就定。

1.2 Tabs 的三段式结构

一个 Tabs 由三部分组成:

  • Tabs 本身:容器,管"当前选中谁、标签栏在哪、怎么排";
  • TabContent:每一叠内容,用 .tabBar(...) 挂上自己的标签;
  • tabBar:标签栏的视觉,可以是字符串、资源图标,或完全自定义的 @Builder 组件。
Tabs({ barPosition: BarPosition.Start }) {
  TabContent() { /* 首页内容 */ }.tabBar('首页')
  TabContent() { /* 发现内容 */ }.tabBar('发现')
  TabContent() { /* 我的内容 */ }.tabBar('我的')
}

注意每个 TabContent 必须配一个 .tabBar,否则该项没有入口;tabBar 的顺序即选项卡的左右顺序。Tabs 的"选中态"由一个索引(从 0 开始)标识,切换本质就是改这个索引。把它想成"一个带指针的转盘"——指针指到哪叠,内容区就显示哪叠,标签栏只是转盘的刻度。

1.3 一次切换发生了什么

用户点标签或滑动时,框架做几件事:更新内部选中索引 → 隐藏旧 TabContent、显示新 TabContent → 触发 onChange 回调。这里有个关键认知:Tabs 默认会保留所有 TabContent 的组件实例(除非你用条件渲染手动卸载)。所以来回切不会重建页面、不会丢滚动位置——这是 Tabs 相比"每次跳转重建"的一大优势。但也意味着:所有页同时在内存里,页面很重时要权衡。后文 3.4、6.2 会展开。

1.4 TabBar 的位置:顶栏还是底栏

TabsbarPosition 控制标签栏在哪:BarPosition.Start(顶部)或 BarPosition.End(底部)。这是个产品决策:

  • 顶部 tabBar:和内容紧邻,适合"同页内的分段筛选"(如订单的"全部/待付/待收"),用户视线不离开内容区;
  • 底部 tabBar:是 App 主框架的经典形态(微信式),拇指最容易够到,适合"几个平级核心频道"。

鸿蒙设计规范里,底部 tabBar 一般 3~5 个,过多会拥挤且误触。把"主框架用底部、页内筛选用顶部"当作默认约定,能省掉很多纠结。

1.5 Tabs 与手势:滑动切换的双刃剑

Tabs 默认支持左右滑动切换(在内容区横滑)。这很顺手,但有副作用:如果 TabContent 内部也有横向滑动组件(如横向 Scroll、横滑图片),手势会打架。框架按方向归责通常能分清(纵向滑归内层、横向滑归 Tabs),但边界情况(如内层刚好横向且占满)可能让 Tabs 抢走滑动。这时可用 scrollable(false) 关掉 Tabs 自身滑动,只留点标签切换。把"是否允许滑切"当作显式决策,而非默认接受,能避开不少嵌套坑。

1.5.1 Tabs 与折叠屏、横竖屏

鸿蒙设备形态丰富(手机、折叠屏、平板),Tabs 在"屏幕会变形"时也有讲究。最典型的是折叠屏展开:外屏时底部四个 tab 排得下,展开到内屏(更宽)后,若用 BarMode.Fixed,四个标签会被拉得更宽、更松散,未必好看;此时若想保持"贴近边缘、居中簇拥"的观感,可在 onConfigurationUpdate 里判断屏宽,宽屏时改用更紧凑的布局或把标签聚到一侧。横竖屏旋转也类似:竖屏底部 tabBar 细长一排很自然,横屏时同一排变矮宽,图标文字可能拥挤,常需在横屏下调整 tabBar 高度或改用侧边栏(把 barPosition 配合旋转逻辑切换)。把这些"屏会动"的场景想在前,Tabs 在多样设备上才稳,否则在直板机上完美、一到折叠屏就露怯。

1.6 为什么 Tabs 是无处不在的"骨架"

几乎每个稍复杂的 App,底层都站着一根 Tabs 主骨架:底部四个频道,每个频道内部再用 TabsSwiper 细分。它之所以普及,是因为它把"上下文切换"的成本压到极低——一次点击/一滑,用户就在不同世界间穿梭,且上下文不丢、状态不毁。理解 Tabs,等于理解了"现代 App 怎么组织信息"。本文就把它从"能切"讲到"切得对、切得美、切得稳"。


2. 环境准备

本文基于以下环境:

  • DevEco Studio 5.0 及以上
  • HarmonyOS SDK API 12(5.0.0)
  • 模拟器:Phone(API 12)

演示工程目录结构:

ohos/
├── AppScope/                 # 应用级配置
├── entry/                    # 入口模块
│   └── src/main/ets/
│       ├── entryability/EntryAbility.ets
│       ├── pages/Index.ets
│       └── components/       # 五个演示组件
└── oh-package.json5          # 模块依赖

说明:本文属于"通用版 HarmonyOS 原生"系列,示例以模拟器验证即可;若你使用 Flutter 专属版(需鸿蒙真机而非模拟器),请注意对应部署差异。

关于运行环境再补两点,避免新手卡在"跑不起来":

  • 签名:模拟器调试可用 DevEco Studio 自动生成的调试证书;真机运行需在 Signing Configs 里配好指纹与 p12/cer/p7b 文件。本文演示工程 module.json5 已声明 ohos.permission.INTERNET,无特殊敏感权限,跑示例足够。
  • 资源占位:编译需 AppScope/resources/base/media/app_icon.pngentry/.../media/icon.png 两张图标。文章正文统一约定为占位资源,请自行放入对应 PNG;其余示例均为纯文本/色块/emoji,不依赖额外图片。

这两步到位后,用 DevEco Studio 打开本 ohos/ 目录直接运行 entry 模块即可看到五个演示页。


3. 核心 API 逐层拆解

3.1 基础分页:三叠内容切换

Tabs 最简用法就是几个 TabContent 配文字 tabBarindex 参数指定初始选中项(默认 0),barMode 控制标签栏排布。

Tabs({ barPosition: BarPosition.Start, index: 0 }) {
  TabContent() {
    Column() { Text('首页内容') }.justifyContent(FlexAlign.Center)
  }.tabBar('首页')

  TabContent() {
    Column() { Text('发现内容') }.justifyContent(FlexAlign.Center)
  }.tabBar('发现')

  TabContent() {
    Column() { Text('我的内容') }.justifyContent(FlexAlign.Center)
  }.tabBar('我的')
}
.barMode(BarMode.Fixed)
.width('100%')
.height('85%')

要点:

  • BarMode.Fixed:标签均分整行宽度,适合少量(≤5)tab;
  • BarMode.Scrollable:标签按内容宽度排、可横向滚动,适合多 tab(如很多分类);
  • 每个 TabContent 内要有确定尺寸的内容,否则切换后空白;
  • tabBar 文字就是标签,无需额外布局。

Tabs 想成"书架上的分隔卡":每张卡标着类名,抽哪张卡就看到哪类书。基础用法就是"贴好卡、放好书"。

关于 barMode 的两种模式,值得把内部机制讲透,因为它直接决定"标签多时怎么排":

  • BarMode.Fixed:所有标签均分标签栏的整行宽度。假设标签栏宽 W、有 N 个 tab,每个占 W/N。优点是对齐整齐、点击区大;缺点是 N 一大(如 8 个),每个被压得很窄,文字容易折行或省略。所以 Fixed 的适用区间是少量 tab(经验上 ≤5),多了就难看。
  • BarMode.Scrollable:每个标签按自身内容宽度排,整体可横向滚动,标签栏右侧还能露出"还有更多"的提示。适合分类很多(如新闻的几十个频道)。缺点是不均分、视觉上没那么"规整",且首屏可能看不到全部标签,需横滑发现。

怎么选?给个量化判据:设标签栏宽 W、单标签最小舒适宽 w_min(含图标+文字约 72vp),若 N × w_min > W,说明 Fixed 会拥挤,改用 Scrollable;反之 Fixed 更整齐。这在平板(W 大)上能塞更多 Fixed tab,手机(W 小)上更早触发 Scrollable——正好呼应前文"屏宽影响布局"。把这条记牢,标签栏就不会在"多分类"场景翻车。

还有个细节:标签栏高度与内容区高度的分配Tabs 总高 = 标签栏高 + 内容区高。标签栏高由 tabBar 自身高度决定(自定义时你给的 height(52)),内容区高 = 总高 − 标签栏高。若 Tabs 给了 height('100%') 但父容器没确定高,整棵会塌。所以 Tabs 也遵循"容器要有确定尺寸"的通用原则,和 Scroll/List 同理。

3.2 TabBar 样式定制:从文字到图标

文字 tabBar 够用,但产品常要"图标 + 文字 + 选中高亮"。这时用 @Builder 自定义 tabBar

@Builder
customTabBar(index: number, name: string, icon: string) {
  Column({ space: 4 }) {
    Text(icon).fontSize(20)            // 可用 emoji 或 $r 图标
    Text(name)
      .fontColor(this.activeIndex === index ? '#4A90D9' : '#888888')
  }
  .width('100%')
  .height(52)
  .justifyContent(FlexAlign.Center)
}

然后在 TabContent().tabBar(this.customTabBar(idx, info.name, info.icon)) 里挂上。选中态靠 activeIndex === index 驱动颜色。注意:activeIndex 要在 onChange 里更新,自定义 tabBar 才能跟着变色——因为 Tabs 自己管的选中索引不会自动同步给你的 @State,需手动桥接。

自定义 tabBar 的要点:

  • 选中色要联动:tabBar 内部读不到 Tabs 的选中态,必须靠外部 @State + onChange 桥接;
  • 尺寸要稳:自定义 tabBar 给固定高(如 52),避免标签栏跳动;
  • 图标来源:可用 emoji 占位,生产用 $r('app.media.xxx') 矢量/位图资源;
  • 底部 tabBar 通常更高(56 左右),给图标+文字留呼吸感。

把"样式定制"记成一句话:tabBar 可以是任何组件,选中态由你自己的状态变量驱动,框架只负责切换内容

3.3 控制器:用 TabsController 编程切换

除了用户点/滑,常需"代码主动切 tab"——比如点"下一步"跳到下一叠、登录成功后跳到"我的"。这要用 TabsController

private controller: TabsController = new TabsController();

Tabs({ barPosition: BarPosition.Start, index: this.activeIndex, controller: this.controller }) {
  // ...
}

// 切到下一个
this.controller.changeIndex(this.activeIndex + 1);
// 跳到指定项
this.controller.changeIndex(3);

绑定后,changeIndex(target)Tabs 切到 target 索引(越界会被夹取到合法范围)。注意 changeIndex 只管"切到哪",不会自动更新你的 activeIndex 状态——所以切完后仍需在 onChange 里同步,或在调用前手动 this.activeIndex = target,否则标签高亮与内容错位。这也是 Tabs 的通用陷阱:控制器切了,但 UI 状态没跟,看起来"切了内容没切标签"。

TabsController 的价值在于把"切换"变成可编排逻辑:引导流程里"上一步/下一步"、表单分步提交、权限变化后跳指定频道,都靠它。比"用户只能手动点"灵活得多。

3.4 事件与状态:onChange 与保留实例

onChange 在每次选中项变化(无论点击还是滑动还是控制器)时触发,回调参数是新索引。它是"感知切换"的唯一窗口:

.onChange((index: number) => {
  this.activeIndex = index;     // 同步高亮
  this.swipeInfo = '切换至 ' + this.tabNames[index];
})

前文提过:Tabs 默认保留所有 TabContent 实例。这意味着:

  • 来回切不重建,滚动位置、输入内容都在(体验好);
  • 但所有页常驻内存,若每页都很重(大列表、多图),首屏内存压力大。

针对"重页",有两种思路:一是用 if/elseLazyForEach 思路按需挂载(切到才建,离开卸载),代价是切换要重建、丢状态;二是保持默认保留,但把每页内部做轻(懒加载列表、图片优化,见 003、007 篇)。选哪种取决于"状态连续性"和"内存"谁更重要。多数 App 主底栏用保留(连续性优先),临时分步表单用按需(内存优先)。

3.5 动态增删:运行时变出标签

真实业务里 tab 数量可能变:用户自定义频道、后台下发新分类。Tabs 搭配响应式数组即可动态增删:

@State tabList: string[] = ['标签 1', '标签 2', '标签 3'];

// 新增
this.tabList.push(`标签 ${this.seq}`);
// 删除当前
this.tabList.splice(this.activeIndex, 1);
if (this.activeIndex >= this.tabList.length) {
  this.activeIndex = this.tabList.length - 1;
}
Tabs({ index: this.activeIndex }) {
  ForEach(this.tabList, (name: string, idx: number) => {
    TabContent() { /* ... */ }.tabBar(name)
  })
}

动态增删三个易错点:

  • 删除后索引越界:删掉当前项后,activeIndex 可能 ≥ 新长度,需夹取(如上 clamp 到末项),否则框架找不到选中项;
  • 删除要连带处理内容状态:若每页有自己的 @State,删 tab 时对应状态要一起清,避免泄漏;
  • barMode 配合:tab 可能变多,ScrollableFixed 更稳,标签多时不会挤变形。

把动态增删记成"数组即真相":tab 列表就是那个 @State 数组,增删数组、Tabs 自动跟着变,你只管维护数组与索引的合法性。


4. 完整可运行代码

下面是五个演示组件的核心片段(完整工程见同目录 ohos/)。

4.1 入口与页面整合

EntryAbility.ets 走标准生命周期,onWindowStageCreateloadContent('pages/Index')Index.etsTabs 把五个演示分页,结构同 3.x 各 demo 的容器写法,仅 tabBar 用文字区分五大演示。

4.2 基础分页组件(BasicTabsDemo)

三个 TabContent(首页/发现/我的),BarMode.FixedonChange 同步 activeIndex。演示"最小可用 Tabs"。

4.3 样式定制组件(TabsStyleDemo)

BarPosition.End(底部),用 @Builder customTabBar 画"emoji 图标 + 文字",选中态由 activeIndex === idx 驱动蓝色高亮。演示"底部带图标的自定义 tabBar"。

4.4 控制器组件(TabsControllerDemo)

private controller: TabsControllerTabs,三个按钮分别 changeIndex(当前-1/当前+1/3)onChange 同步 activeIndex,避免高亮错位。演示"代码主动切 tab"。

4.5 事件状态组件(TabsEventDemo)

面板展示"当前选中项"与"最近切换来源",onChange 更新两者。标签栏用 BarMode.Scrollable(5 个),演示"事件感知 + 状态反馈"。

4.6 动态增删组件(TabsDynamicDemo)

@State tabList 初始 3 项,“新增标签” push、“删除当前” splice 并 clamp activeIndexForEach 渲染。演示"运行期变出/收起 tab"。

4.7 TabBar 与 Accessibility:别只靠颜色

补一个常被漏的点:选中态若只用颜色区分(如灰→蓝),色弱用户可能分不清。无障碍做法:同时给选中项加"加粗字体/小圆点指示器/字重变化",让区分不依赖色觉。底部 tabBar 还应给每个项 accessibilityDescription(如"首页,已选中"),读屏用户才能感知当前在哪。这类成本极低、覆盖却广,验收时容易被漏,建议写进规范。

4.8 场景化示例:底部四频道主框架

把上文能力拼成最常见的"微信式"主框架:底部 BarPosition.End + 四个自定义 tabBar(首页/消息/动态/我的),每个 TabContent 内是各自的页面(可再嵌套 TabsList)。关键:底部 tabBar 固定高、选中高亮、保留实例(切换不丢滚动)。这是 Tabs 最高频的落地点,理解它就等于理解了大多数 App 的骨架。

4.9 场景化示例:订单分段筛选 + 顶部 tab

另一高频场景:订单页顶部 BarPosition.Start + "全部/待付款/待发货/待收货/待评价"五个 tab(Scrollable),每个 TabContent 内是对应状态的订单 List。关键:顶部 tab 与内容紧邻、切 tab 即切筛选条件;配合 007 篇的 List 懒加载,数据量大也稳。这种"页内分段"与"底部主框架"是 Tabs 的两大主战场。


5. 模拟器验证

本文所有示例在 DevEco Studio 模拟器(Phone API 12)验证通过。验证要点:

  1. 基础分页:三叠内容点击/横滑切换正常,选中高亮正确;
  2. 样式定制:底部 emoji+文字 tabBar,选中变蓝,滑动切换顺;
  3. 控制器:三个按钮分别上一/下一/跳设置,内容高亮同步不串;
  4. 事件状态:面板实时显示当前项与切换来源;
  5. 动态增删:新增标签出现并可切,删除当前项后索引不越界、不白屏。

模拟器与真机在滑动手感上可能有细微差异(触控采样率),但功能逻辑一致。若真机上发现滑动切换过灵敏/迟钝,可用 Tabs 相关手势参数微调。

把"模拟器验证"和"真机校准"的差异再整理成一张表:

维度 模拟器表现 真机表现 是否需处理
点击切换 一致 一致 无需
滑动切换 鼠标拖拽触发 真实手指横滑 一般无需
标签栏布局 一致 不同屏宽可能折行 多 tab 用 Scrollable
底部手势区 可能与系统手势条冲突 底部 tabBar 留安全距离
重页内存 与真机逻辑一致 低端机更敏感 真机测一次内存

一句话:模拟器负责把"功能跑通、逻辑正确",真机负责把"手感校准、布局验收"。本文示例上真机功能应一致;布局类差异按上表逐项核对。


6. 调试与排错

6.1 为什么点标签没反应

按优先级排查:

  1. TabContent 没配 .tabBar:该项无入口,等于不存在;
  2. Tabs 没给确定高度:内容区高度未知,切换后空白,看起来"没切";
  3. scrollable 与嵌套冲突:内层横滑抢手势,标签点不到——检查是否需 scrollable(false)
  4. activeIndex 越界index 初始值大于 tab 数,框架夹取到末项,表现异常。

一句话:标签要配齐、容器要给高、手势别打架,切换才正常。

6.2 切换后内容空白 / 状态丢失

  • 每次切换都重建(状态丢、滚动归零):可能是你在 TabContent 里用了条件渲染把内容卸载了;默认 Tabs 保留实例,别额外卸载;
  • 切换后空白TabContent 内部内容没确定尺寸,或 Tabs 高度被父容器压成 0;
  • 想主动丢状态省内存:用按需挂载(切走卸载),但接受重建代价,并在 onChange 里重置必要状态。

6.3 自定义 tabBar 不高亮

最常见原因:activeIndex 没在 onChange 里更新,或自定义 tabBar 内没读 activeIndex。记住"框架只切内容、不切你的状态"——必须手动桥接。changeIndex 后也要同步,否则控制器切了内容、标签不变色。

6.4 动态增删后白屏/错位

  • 删除当前项后 activeIndex 越界:clamp 到 length - 1
  • 新增后没滚到新项:若想聚焦新 tab,调 controller.changeIndex(新索引)
  • 数组用普通变量而非 @StateForEach 不刷新,tab 不增。务必 @State

6.5 标签栏挤压变形

tab 多且 BarMode.Fixed 时,标签被均分压窄、文字折行。解决:改 BarMode.Scrollable 让标签按内容宽并可横滑;或精简标签文字、缩短命名。固定模式只适合少量 tab。


7. 总结与扩展

7.1 本文知识地图

Tabs 核心能力收成一张图,便于回顾:

Tabs 选项卡

基础: Tabs+TabContent+tabBar

样式: @Builder 自定义/图标

控制: TabsController.changeIndex

事件: onChange 同步状态

动态: @State 数组增删

barMode Fixed/Scrollable

选中态靠外部状态桥接

切了内容要同步 activeIndex

删后 clamp 索引

7.2 与 Swiper / Navigation 的取舍再强调

回顾前文:Tabs 用于"同屏多上下文随时切";Swiper 用于"整屏翻页看完即过";Navigation 用于"栈式进栈出栈"。三者不是替代,而是 Tabs 常作主骨架,TabContent 内部再嵌 Swiper(首页轮播)或 Navigation(设置树)。把它们的关系理清,导航架构就不乱。

7.3 三个真实踩坑复盘

  • 案例一:底部 tabBar 选中不变色。开发者用了自定义 tabBar,却忘了 onChangeactiveIndex,结果框架切了内容、标签全灰。补同步后正常。
  • 案例二:删掉当前 tab 后白屏。删完没 clamp activeIndex,索引指向已不存在的项。夹取到末项后恢复。
  • 案例三:订单页切 tab 后列表重建、滚动归零。误在 TabContent 内加了条件卸载。去掉后保留实例,切换丝滑。

这类问题共性:都不是某一行写错,而是"有没有把 Tabs 当作一等公民去设计"——结构、样式桥接、控制器同步、保留实例、动态合法性五环都照顾到,问题在写代码时就能消灭。

补充一句关于"Tabs 与状态管理"的衔接:当主框架选中项需要跨页面共享(如"从详情页返回要停在原频道"),把 activeIndex 提到 AppStorage/LocalStorage 或用 @Observed 视图模型承载(见 051–075 篇)更稳。但高频切换状态别过度提升,否则无关组件跟着刷。核心原则:状态作用域刚好覆盖用到它的视图。结合前文"保留实例"思路,主框架在复杂应用里也能既灵活又高效。

7.4 后续可沿三条线深入

  1. 与 Navigation 组合:主框架 Tabs 内嵌栈式导航的复杂架构;
  2. 与 Swiper 组合:首页 Tabs 中某频道内嵌整屏轮播;
  3. 进阶交互Tabs 与下拉刷新、Tabs 滑动与内层滚动的精细手势(nestedScroll)协同。

回到开头:Tabs 是把世界切成几叠、让用户随时来回穿梭的骨架。本文从"三段式结构"的心智模型出发,逐层拆了基础分页、样式定制、控制器、事件状态、动态增删五环,又用样式桥接、保留实例、索引 clamp、无障碍、真机校准把这些环落到实处。写 Tabs 时若能始终记住"框架只切内容、状态要自己桥接",那么无论是底部主框架、顶部筛选还是动态频道,都能既切得对、又切得美。把这份理解带回项目,下一个"标签不高亮/删完白屏"的工单,大概就能在写代码时消灭在萌芽里。

Tabs 是 ArkUI 里最"骨架级"的组件:用户天天点它却不觉得它存在,可一旦它错位、不切、丢状态,整个 App 的导航就散了架。把本文五环吃透,你就能从容撑起从简单分段到复杂多频道主框架的全部切换场景。

Logo

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

更多推荐