鸿蒙 ArkUI Tabs 选项卡切换:分类导航、滑动联动与懒加载
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 的位置:顶栏还是底栏
Tabs 用 barPosition 控制标签栏在哪: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 主骨架:底部四个频道,每个频道内部再用 Tabs 或 Swiper 细分。它之所以普及,是因为它把"上下文切换"的成本压到极低——一次点击/一滑,用户就在不同世界间穿梭,且上下文不丢、状态不毁。理解 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.png与entry/.../media/icon.png两张图标。文章正文统一约定为占位资源,请自行放入对应 PNG;其余示例均为纯文本/色块/emoji,不依赖额外图片。
这两步到位后,用 DevEco Studio 打开本 ohos/ 目录直接运行 entry 模块即可看到五个演示页。
3. 核心 API 逐层拆解
3.1 基础分页:三叠内容切换
Tabs 最简用法就是几个 TabContent 配文字 tabBar。index 参数指定初始选中项(默认 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/else 或 LazyForEach 思路按需挂载(切到才建,离开卸载),代价是切换要重建、丢状态;二是保持默认保留,但把每页内部做轻(懒加载列表、图片优化,见 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 可能变多,Scrollable比Fixed更稳,标签多时不会挤变形。
把动态增删记成"数组即真相":tab 列表就是那个 @State 数组,增删数组、Tabs 自动跟着变,你只管维护数组与索引的合法性。
4. 完整可运行代码
下面是五个演示组件的核心片段(完整工程见同目录 ohos/)。
4.1 入口与页面整合
EntryAbility.ets 走标准生命周期,onWindowStageCreate 里 loadContent('pages/Index')。Index.ets 用 Tabs 把五个演示分页,结构同 3.x 各 demo 的容器写法,仅 tabBar 用文字区分五大演示。
4.2 基础分页组件(BasicTabsDemo)
三个 TabContent(首页/发现/我的),BarMode.Fixed,onChange 同步 activeIndex。演示"最小可用 Tabs"。
4.3 样式定制组件(TabsStyleDemo)
BarPosition.End(底部),用 @Builder customTabBar 画"emoji 图标 + 文字",选中态由 activeIndex === idx 驱动蓝色高亮。演示"底部带图标的自定义 tabBar"。
4.4 控制器组件(TabsControllerDemo)
private controller: TabsController 绑 Tabs,三个按钮分别 changeIndex(当前-1/当前+1/3);onChange 同步 activeIndex,避免高亮错位。演示"代码主动切 tab"。
4.5 事件状态组件(TabsEventDemo)
面板展示"当前选中项"与"最近切换来源",onChange 更新两者。标签栏用 BarMode.Scrollable(5 个),演示"事件感知 + 状态反馈"。
4.6 动态增删组件(TabsDynamicDemo)
@State tabList 初始 3 项,“新增标签” push、“删除当前” splice 并 clamp activeIndex,ForEach 渲染。演示"运行期变出/收起 tab"。
4.7 TabBar 与 Accessibility:别只靠颜色
补一个常被漏的点:选中态若只用颜色区分(如灰→蓝),色弱用户可能分不清。无障碍做法:同时给选中项加"加粗字体/小圆点指示器/字重变化",让区分不依赖色觉。底部 tabBar 还应给每个项 accessibilityDescription(如"首页,已选中"),读屏用户才能感知当前在哪。这类成本极低、覆盖却广,验收时容易被漏,建议写进规范。
4.8 场景化示例:底部四频道主框架
把上文能力拼成最常见的"微信式"主框架:底部 BarPosition.End + 四个自定义 tabBar(首页/消息/动态/我的),每个 TabContent 内是各自的页面(可再嵌套 Tabs 或 List)。关键:底部 tabBar 固定高、选中高亮、保留实例(切换不丢滚动)。这是 Tabs 最高频的落地点,理解它就等于理解了大多数 App 的骨架。
4.9 场景化示例:订单分段筛选 + 顶部 tab
另一高频场景:订单页顶部 BarPosition.Start + "全部/待付款/待发货/待收货/待评价"五个 tab(Scrollable),每个 TabContent 内是对应状态的订单 List。关键:顶部 tab 与内容紧邻、切 tab 即切筛选条件;配合 007 篇的 List 懒加载,数据量大也稳。这种"页内分段"与"底部主框架"是 Tabs 的两大主战场。
5. 模拟器验证
本文所有示例在 DevEco Studio 模拟器(Phone API 12)验证通过。验证要点:
- 基础分页:三叠内容点击/横滑切换正常,选中高亮正确;
- 样式定制:底部 emoji+文字 tabBar,选中变蓝,滑动切换顺;
- 控制器:三个按钮分别上一/下一/跳设置,内容高亮同步不串;
- 事件状态:面板实时显示当前项与切换来源;
- 动态增删:新增标签出现并可切,删除当前项后索引不越界、不白屏。
模拟器与真机在滑动手感上可能有细微差异(触控采样率),但功能逻辑一致。若真机上发现滑动切换过灵敏/迟钝,可用 Tabs 相关手势参数微调。
把"模拟器验证"和"真机校准"的差异再整理成一张表:
| 维度 | 模拟器表现 | 真机表现 | 是否需处理 |
|---|---|---|---|
| 点击切换 | 一致 | 一致 | 无需 |
| 滑动切换 | 鼠标拖拽触发 | 真实手指横滑 | 一般无需 |
| 标签栏布局 | 一致 | 不同屏宽可能折行 | 多 tab 用 Scrollable |
| 底部手势区 | 无 | 可能与系统手势条冲突 | 底部 tabBar 留安全距离 |
| 重页内存 | 与真机逻辑一致 | 低端机更敏感 | 真机测一次内存 |
一句话:模拟器负责把"功能跑通、逻辑正确",真机负责把"手感校准、布局验收"。本文示例上真机功能应一致;布局类差异按上表逐项核对。
6. 调试与排错
6.1 为什么点标签没反应
按优先级排查:
TabContent没配.tabBar:该项无入口,等于不存在;Tabs没给确定高度:内容区高度未知,切换后空白,看起来"没切";scrollable与嵌套冲突:内层横滑抢手势,标签点不到——检查是否需scrollable(false);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(新索引); - 数组用普通变量而非
@State:ForEach不刷新,tab 不增。务必@State。
6.5 标签栏挤压变形
tab 多且 BarMode.Fixed 时,标签被均分压窄、文字折行。解决:改 BarMode.Scrollable 让标签按内容宽并可横滑;或精简标签文字、缩短命名。固定模式只适合少量 tab。
7. 总结与扩展
7.1 本文知识地图
把 Tabs 核心能力收成一张图,便于回顾:
7.2 与 Swiper / Navigation 的取舍再强调
回顾前文:Tabs 用于"同屏多上下文随时切";Swiper 用于"整屏翻页看完即过";Navigation 用于"栈式进栈出栈"。三者不是替代,而是 Tabs 常作主骨架,TabContent 内部再嵌 Swiper(首页轮播)或 Navigation(设置树)。把它们的关系理清,导航架构就不乱。
7.3 三个真实踩坑复盘
- 案例一:底部 tabBar 选中不变色。开发者用了自定义 tabBar,却忘了
onChange写activeIndex,结果框架切了内容、标签全灰。补同步后正常。 - 案例二:删掉当前 tab 后白屏。删完没 clamp
activeIndex,索引指向已不存在的项。夹取到末项后恢复。 - 案例三:订单页切 tab 后列表重建、滚动归零。误在
TabContent内加了条件卸载。去掉后保留实例,切换丝滑。
这类问题共性:都不是某一行写错,而是"有没有把 Tabs 当作一等公民去设计"——结构、样式桥接、控制器同步、保留实例、动态合法性五环都照顾到,问题在写代码时就能消灭。
补充一句关于"Tabs 与状态管理"的衔接:当主框架选中项需要跨页面共享(如"从详情页返回要停在原频道"),把 activeIndex 提到 AppStorage/LocalStorage 或用 @Observed 视图模型承载(见 051–075 篇)更稳。但高频切换状态别过度提升,否则无关组件跟着刷。核心原则:状态作用域刚好覆盖用到它的视图。结合前文"保留实例"思路,主框架在复杂应用里也能既灵活又高效。
7.4 后续可沿三条线深入
- 与 Navigation 组合:主框架
Tabs内嵌栈式导航的复杂架构; - 与 Swiper 组合:首页
Tabs中某频道内嵌整屏轮播; - 进阶交互:
Tabs与下拉刷新、Tabs滑动与内层滚动的精细手势(nestedScroll)协同。
回到开头:Tabs 是把世界切成几叠、让用户随时来回穿梭的骨架。本文从"三段式结构"的心智模型出发,逐层拆了基础分页、样式定制、控制器、事件状态、动态增删五环,又用样式桥接、保留实例、索引 clamp、无障碍、真机校准把这些环落到实处。写 Tabs 时若能始终记住"框架只切内容、状态要自己桥接",那么无论是底部主框架、顶部筛选还是动态频道,都能既切得对、又切得美。把这份理解带回项目,下一个"标签不高亮/删完白屏"的工单,大概就能在写代码时消灭在萌芽里。
Tabs 是 ArkUI 里最"骨架级"的组件:用户天天点它却不觉得它存在,可一旦它错位、不切、丢状态,整个 App 的导航就散了架。把本文五环吃透,你就能从容撑起从简单分段到复杂多频道主框架的全部切换场景。
更多推荐


所有评论(0)