【HarmonyOS 7 悬浮页签深度实战】04 HdsTabsController 如何协调页签切换与显隐
前言
任务列表里常见一个入口:用户在首页看到“查看待办”,点击以后直接进入任务页。页面已经采用 HdsTabs 时,这次跳转不能再由业务代码模拟一次 TabBar 点击,也不适合同时维护一份脱离组件的选中状态。HdsTabsController 提供的 changeIndex() 可以发起切换,切换结果由 HdsTabs 的事件写回业务状态。
显隐也会遇到类似问题。进入专注阅读、全屏预览或临时操作状态后,页面可能需要收起底部页签栏,退出时恢复。当前 API 提供 applyHideAnimation() 和 applyShowAnimation(),调用对象仍然是 HdsTabsController。这两个方法控制页签栏与 MiniBar 的显示隐藏动效,不会销毁 TabContent,也不会替业务层保存“为什么隐藏”这类页面状态。
项目里真正需要理清的是三份状态:HdsTabs 正在显示哪个页签,业务最近发出了什么切换或显隐命令,以及每个 TabContent 自己保存的数据。它们混在一个布尔值或一个索引里,外部按钮、用户点击和左右滑动逐渐增多以后,页面文字、业务状态与 TabBar 选中项就可能无法对应。
HdsTabs 与 HdsTabsController 从 6.0.0(20) 开始提供。控制器继承 ArkUI 的 TabsController,所以主动切换继续调用 changeIndex(value: number): void。显示和隐藏方法、HdsAnimationMode 以及 onSelected 从 6.1.0(23) 开始提供。当前接口只支持 Stage 模型,系统能力为 SystemCapability.UIDesign.HDSComponent.Core,在 TV 上没有效果。

一、Controller 接收主动切换命令
外部按钮跳转到任务页时,调用关系可以保持简洁:
private requestTab(index: number): void {
this.pendingIndex = index;
this.transitionText =
`业务已请求切换到:${this.getTabName(index)}`;
this.tabsController.changeIndex(index);
}
changeIndex() 的索引从 0 开始。当前 Demo 中首页、任务、我的分别是 0、1、2。ArkUI TabsController 对越界值会按默认索引 0 处理,但业务按钮不该依赖这个兜底。动态索引来自路由参数或服务端配置时,调用前检查范围,能够避免错误值导致页面返回首页。
一个 HdsTabsController 只能控制一个 HdsTabs。页面同时保留普通模式与悬浮模式时,两套容器需要分别创建控制器;只保留一个悬浮容器时,创建一个控制器即可。同一个实例如果绑定给多个 HdsTabs,代码量虽然有所减少,控制目标却不再可靠。
控制器还要跟着当前页面实例长期存在。适合的写法是在组件字段中创建一次,并通过 HdsTabs({ controller: this.tabsController }) 传入;不要在 build()、@Builder 或每次按钮点击时重新 new HdsTabsController()。声明式界面会反复构建 UI 描述,如果控制器也跟着重建,按钮持有的实例和屏幕上 HdsTabs 实际绑定的实例就可能分开。此时外部调用即使已经执行,页面也不会切换。
项目若通过条件渲染在普通 Tabs 与 HdsTabs 之间切换,两个容器各自保存控制器。模式切换会创建新的容器实例,原容器的选中状态不能当作新容器的可靠初值。业务层可以保留 currentIndex,新容器完成挂载后用它恢复目标索引。共享控制器无法替代这次页面模式迁移。
控制器只负责发起切换,它没有读取当前索引的方法。业务层要显示当前页签、记录埋点或决定按钮状态,可以监听 HdsTabs 事件:
.onSelected((index: number) => {
this.pendingIndex = index;
this.transitionText =
`开始切换到:${this.getTabName(index)}`;
})
.onChange((index: number) => {
this.currentIndex = index;
this.pendingIndex = index;
this.transitionText =
`当前已显示:${this.getTabName(index)}`;
})
两次回调的时点不同。onSelected 在切换开始时触发,onChange 在切换完成后触发;调用 changeIndex()、点击 TabBar、滑动内容页或修改绑定索引,都可以进入这条事件路径。Demo 以 pendingIndex 表示切换目标,以 currentIndex 表示已经完成切换的页面索引。需要立即预加载数据时可以观察前者,页面标题、业务统计和最终选中状态使用后者会更可靠。
onSelected 里不要再次调用 changeIndex(),否则一次选中可能继续发起下一次切换。遇到“任务页需要登录”“某个页签暂时不可用”之类的业务限制,可以在外部按钮发起切换前完成权限判断;TabBar 本身也要在交互设计上给出禁用或替代入口。在 onSelected 中处理拦截逻辑,用户点击、滑动和代码切换会彼此触发,最终难以确认哪一次才是原始动作。
连续快速点击还会让 pendingIndex 在短时间内多次变化。它适合显示当前目标页签,不适合直接提交结算、保存表单或记录最终到达页。只需要执行一次的业务副作用可以集中到 onChange,并按最终索引去重;预加载若在 onSelected 中启动,则要让请求能够按目标索引复用或取消。这样既保留提前准备数据的机会,也不会把一次快速切换记录成多次页面到达。
当前示例给 HdsTabs 显式设置了 240ms 动画。底部页签使用 BottomTabBarStyle 且没有设置 animationDuration 时,切换时长默认是 0;显式设置时长,方便观察 onSelected 与 onChange 的先后。240ms 只是一项实验值,正式项目要结合页面内容和实际操作手感调整。
用户手动点击“我的”以后,业务代码不需要额外编写同步逻辑。onSelected 收到 2,onChange 随后把 currentIndex 更新为 2。外部按钮调用 changeIndex(1) 时也会经过相同路径。入口不同,最终状态都从组件事件回到同一个字段,页面就不会出现“内容已经到了任务页,业务文字仍显示首页”的情况。

二、显隐命令控制底栏
页面顶部的“隐藏页签”和“恢复页签”按钮位于 HdsTabs 外部。这样页签栏收起后,恢复入口仍然可见。隐藏调用如下:
this.tabsController.applyHideAnimation(
HdsAnimationMode.CLICK_ANIMATION
);
恢复时调用:
this.tabsController.applyShowAnimation(
HdsAnimationMode.CLICK_ANIMATION
);
HdsAnimationMode 目前有 SCROLL_ANIMATION 和 CLICK_ANIMATION 两个值。它描述触发动效的交互场景。按钮点击使用 CLICK_ANIMATION;页面把底栏显隐绑定到滚动时,可以评估 SCROLL_ANIMATION。它不表示显示或隐藏方向,方向由 applyShowAnimation() 与 applyHideAnimation() 决定。
这组方法会调用页签栏和 MiniBar 的显示隐藏动效。当前 Demo 没有 MiniBar,所以画面里只需要观察 TabBar。包含 MiniBar 的页面调用同一方法时,两块底部区域一起变化,不能把它当成“只隐藏三个页签入口”的局部接口。
控制器也没有提供当前可见性的 getter,两个方法返回 void,HdsTabs API 中没有对应的显隐完成回调。Demo 因此把字段命名为 requestedBarVisible,含义是业务最近发出的显隐意图。按钮点击后页面可以显示“已请求隐藏页签栏”,却不能仅凭这个布尔值宣称动效已经完成。复杂业务流程如果依赖显隐完成时点,只能使用当前版本实际提供的页面事件,并结合人工运行结果,不能虚构控制器状态查询接口。
页签栏隐藏以后,HdsTabs 和三个 TabContent 仍然保留在组件树中。当前索引没有因为隐藏命令自动归零,每个页面记录的操作次数也不能清空。恢复页签栏时,项目需要重点检查三件事:恢复前后选中入口是否一致,内容区有没有因为底栏离开而突然改变可操作空间,悬浮页签重新出现后是否遮挡底部按钮或最后一项内容。
这里要区分“让底栏不可见”和“把整个 HdsTabs 移出组件树”。applyHideAnimation() 处理前一种需求,适合阅读模式、临时全屏和滚动收起;通过 if 条件直接移除 HdsTabs 属于后一种做法,容器及其子页面可能重新创建,页面局部状态也要重新评估。Demo 采用控制器显隐,任务页的操作次数会继续保存在原来的页面实例中。
显隐会改变画面中的可见区域,业务内容是否重新排版仍由页面结构决定。barOverlap=true 时,TabBar 原本就叠在 TabContent 上方;隐藏以后,内容区不会因此自动增加一份业务留白,恢复时也不会自动检查页面按钮是否被覆盖。项目若按底栏状态动态修改底部间距,需要保存“阅读模式”“全屏预览”等业务原因,由同一处布局策略计算间距,避免让多个按钮各自修改一份 padding。
布局检查最好以同一页内容作为前后对照。准备一条贴近底部的任务卡片和一个可点击按钮,分别记录页签栏显示、隐藏、恢复三个时点:卡片位置是否变化,按钮是否仍能点击,底部滚动是否能完整露出最后一项。只观察空白页面很难发现遮挡。当前自动化环境缺少 API 26 镜像,所以代码仅提供稳定的状态标记和操作入口,位置、动画与触摸结果仍保留给同一设备环境中的人工观察。
三、页签状态、显隐意图和页面数据分开保存
Demo 有三类状态,分别解决不同问题:
| 状态 | 保存位置 | 更新来源 | 用途 |
|---|---|---|---|
currentIndex |
页面业务层 | onChange |
当前已经显示的页签、标题和统计 |
pendingIndex |
页面业务层 | 外部请求、onSelected |
正在切换的目标页签 |
requestedBarVisible |
页面业务层 | 显示、隐藏按钮 | 记录最近一次显隐意图,不冒充组件实测状态 |
| 页面操作次数 | 当前 Entry 页面 | 各 TabContent 内按钮 | 验证切换、隐藏、恢复后业务数据是否保留 |
这种状态我一般不会保存在单个 TabContent 里。外部按钮和三个页面都要读取当前页签,在 HdsTabs 外层保存状态可以减少同步次数。某个页面独有的筛选条件、滚动位置或表单内容仍然保存在自己的业务组件中,无需全部提升到导航层。
还要给每个字段规定唯一的最终写入入口。currentIndex 只在 onChange 中确认,外部按钮只修改 pendingIndex 并发出命令;页面操作次数只由对应页面的业务按钮修改。若“去任务页”按钮在调用控制器前就把 currentIndex 设为 1,切换因索引错误、容器未挂载或后续操作被改写时,顶部会提前显示已经到达。状态分开以后,调试时也能直接判断问题停在命令发送、切换开始还是切换完成。
页签配置来自动态数据时,索引与业务标识之间还要增加一层映射。服务端返回的任务入口可能被隐藏,原来的索引 1 随之变成别的页面;这时持久化 currentIndex=1 没有业务含义。项目可以保存稳定的页面标识,例如 home、task、profile,构建出当前可见页签后换算成索引,并在调用 changeIndex() 前确认目标仍存在。Demo 的三个入口是固定顺序,所以直接使用 0、1、2,不能把这个简化照搬到可动态增删的生产导航。
页面重新进入前台时也不要仅凭上一次 requestedBarVisible 推断底栏已经处于同一视觉状态。该字段记录的是业务意图,系统中断、页面重建或容器重新创建都可能让视觉状态重新初始化。恢复流程要根据业务模式,让当前页面实例重新发出相符的显示或隐藏命令;最终画面仍需通过实际运行确认。这样字段职责始终明确,也不会把历史命令当成组件 getter 的替代品。
冷启动路由与页面内按钮还要分开处理。路由目标可能在 HdsTabs 绑定控制器以前到达,此时立刻调用 changeIndex(),不能保证屏幕上的容器已经收到命令。路由目标到达时保存稳定的页面标识与目标索引,页面结构就绪后恢复;页面已经显示以后,按钮仍由 Controller 处理。冷启动、后台唤醒和页面内跳转分别检查一次,能够发现只在挂载时序下出现的失效。
运行时可以依次检查:
- 首页点击一次“记录页面操作”,确认首页次数变为 1。
- 点击顶部“去任务页”,观察切换目标变成任务,完成后当前索引变为 1。
- 在任务页记录一次操作,然后手动点击“我的”,确认
onChange把当前索引更新为 2。 - 点击“隐藏页签”,观察 TabBar 隐藏时内容页面仍然保留,顶部恢复按钮可以继续使用。
- 点击“恢复页签”,确认选中项仍为“我的”,三个页面的操作次数没有归零。

出现状态不一致时,可以按现象缩小范围:
| 页面现象 | 优先检查 |
|---|---|
| 外部按钮没有切换页面 | 控制器是否绑定当前 HdsTabs,索引是否在 0~2 范围内 |
| 页面已切换,顶部索引没更新 | 是否在 onChange 中更新业务状态 |
| 点击 TabBar 后业务状态滞后 | 是否只在外部按钮里修改索引,没有监听组件事件 |
| 隐藏后没有恢复入口 | 恢复按钮是否位于会一起隐藏或不可达的底部区域 |
| 恢复后页面数据归零 | 是否误用条件渲染销毁整个 HdsTabs,导致页面重新创建 |
| 按钮文字显示已隐藏,画面仍在动 | 业务字段记录的是请求意图,继续等待并观察实际动效 |
总结
业务按钮主动跳转时,把目标索引交给 HdsTabsController.changeIndex(),分别通过 onSelected 记录切换目标、通过 onChange 保存最终索引。用户点击 TabBar、滑动页面和业务按钮跳转会回到同一条状态同步路径,导航状态无需在多个入口里重复维护。
页签栏显隐由控制器的 applyHideAnimation() 与 applyShowAnimation() 处理。按钮场景使用 HdsAnimationMode.CLICK_ANIMATION,滚动场景选择 SCROLL_ANIMATION。这两个方法控制 TabBar 与 MiniBar 的动效,不提供当前可见性查询,也不替业务层保存阅读模式、全屏预览或页面数据。
我目前手里还没有可以测试 HarmonyOS 7 的真机,所以现在只能先在模拟器里验证,最终效果还是要以真机实际运行结果为准。
完整代码
Main.ets
/**
* HarmonyOS 7 悬浮页签深度实战 04
*
*/
import {
HdsAnimationMode,
HdsTabs,
HdsTabsController
} from '@kit.UIDesignKit';
@Entry
@Component
struct Main {
private tabsController: HdsTabsController =
new HdsTabsController();
@State private currentIndex: number = 0;
@State private pendingIndex: number = 0;
@State private requestedBarVisible: boolean = true;
@State private transitionText: string = '当前已显示:首页';
@State private homeActionCount: number = 0;
@State private taskActionCount: number = 0;
@State private profileActionCount: number = 0;
private getTabName(index: number): string {
if (index === 1) {
return '任务';
}
if (index === 2) {
return '我的';
}
return '首页';
}
private getActionCount(index: number): number {
if (index === 1) {
return this.taskActionCount;
}
if (index === 2) {
return this.profileActionCount;
}
return this.homeActionCount;
}
private recordPageAction(index: number): void {
if (index === 1) {
this.taskActionCount++;
return;
}
if (index === 2) {
this.profileActionCount++;
return;
}
this.homeActionCount++;
}
private requestTab(index: number): void {
if (index < 0 || index > 2) {
this.transitionText = '页签索引超出 0~2,已取消切换';
return;
}
this.pendingIndex = index;
this.transitionText =
`业务已请求切换到:${this.getTabName(index)}`;
this.tabsController.changeIndex(index);
}
private requestBarVisibility(visible: boolean): void {
this.requestedBarVisible = visible;
if (visible) {
this.transitionText = '业务已请求恢复页签栏';
this.tabsController.applyShowAnimation(
HdsAnimationMode.CLICK_ANIMATION
);
return;
}
this.transitionText = '业务已请求隐藏页签栏';
this.tabsController.applyHideAnimation(
HdsAnimationMode.CLICK_ANIMATION
);
}
@Builder
private controlPanel() {
Column({ space: 10 }) {
Row({ space: 8 }) {
Button('去任务页')
.layoutWeight(1)
.height(38)
.fontSize(12)
.onClick(() => {
this.requestTab(1);
})
Button('回首页')
.layoutWeight(1)
.height(38)
.fontSize(12)
.onClick(() => {
this.requestTab(0);
})
}
.width('100%')
Row({ space: 8 }) {
Button('隐藏页签')
.layoutWeight(1)
.height(38)
.fontSize(12)
.fontColor('#5065E8')
.backgroundColor('#EEF1FF')
.onClick(() => {
this.requestBarVisibility(false);
})
Button('恢复页签')
.layoutWeight(1)
.height(38)
.fontSize(12)
.fontColor('#5065E8')
.backgroundColor('#EEF1FF')
.onClick(() => {
this.requestBarVisibility(true);
})
}
.width('100%')
}
.width('100%')
}
@Builder
private statePanel() {
Column({ space: 7 }) {
Row() {
Text('当前索引')
.fontSize(12)
.fontColor('#68708A')
Blank()
Text(`${this.currentIndex} · ${this.getTabName(this.currentIndex)}`)
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor('#17203A')
}
.width('100%')
Row() {
Text('切换目标')
.fontSize(12)
.fontColor('#68708A')
Blank()
Text(`${this.pendingIndex} · ${this.getTabName(this.pendingIndex)}`)
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor('#5065E8')
}
.width('100%')
Row() {
Text('最近显隐意图')
.fontSize(12)
.fontColor('#68708A')
Blank()
Text(this.requestedBarVisible ? '请求显示' : '请求隐藏')
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor('#17203A')
}
.width('100%')
Text(this.transitionText)
.fontSize(12)
.fontColor('#747C92')
.lineHeight(18)
.width('100%')
}
.width('100%')
.padding(14)
.backgroundColor(Color.White)
.borderRadius(18)
.alignItems(HorizontalAlign.Start)
}
@Builder
private tabPage(
index: number,
title: string,
description: string
) {
Scroll() {
Column({ space: 14 }) {
Text(title)
.fontSize(27)
.fontWeight(FontWeight.Bold)
.fontColor('#11182C')
.width('100%')
Text(description)
.fontSize(14)
.fontColor('#68708A')
.lineHeight(21)
.width('100%')
Column({ space: 8 }) {
Text('页面业务状态')
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor('#5065E8')
.width('100%')
Text(`已记录操作:${this.getActionCount(index)} 次`)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#17203A')
.width('100%')
Text('切换或隐藏页签栏以后,这个计数应继续保留。')
.fontSize(12)
.fontColor('#68708A')
.lineHeight(18)
.width('100%')
Button('记录一次页面操作')
.width('100%')
.height(40)
.margin({ top: 4 })
.onClick(() => {
this.recordPageAction(index);
})
}
.width('100%')
.padding(18)
.backgroundColor(Color.White)
.borderRadius(20)
.alignItems(HorizontalAlign.Start)
Column({ space: 8 }) {
Text('观察位置')
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor('#17203A')
.width('100%')
Text(
'顶部控制区位于 HdsTabs 外部,页签栏隐藏后仍可恢复。'
)
.fontSize(12)
.fontColor('#68708A')
.lineHeight(18)
.width('100%')
}
.width('100%')
.padding(18)
.backgroundColor('#E9EDFF')
.borderRadius(20)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding({
left: 20,
right: 20,
top: 16,
bottom: 130
})
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
.backgroundColor('#F4F6FB')
}
build() {
Column({ space: 10 }) {
Column({ space: 5 }) {
Text('HarmonyOS 7 悬浮页签')
.fontSize(26)
.fontWeight(FontWeight.Bold)
.fontColor('#11182C')
.width('100%')
Text('主动切换、状态同步与页签显隐')
.fontSize(14)
.fontColor('#68708A')
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
this.controlPanel()
this.statePanel()
Column() {
HdsTabs({
controller: this.tabsController
}) {
TabContent() {
this.tabPage(
0,
'首页',
'从首页的业务按钮主动进入任务页。'
)
}
.tabBar(
new BottomTabBarStyle(
$r('sys.media.ohos_ic_public_clock'),
'首页'
)
)
TabContent() {
this.tabPage(
1,
'任务',
'记录一次任务操作,再切换页面或隐藏页签栏。'
)
}
.tabBar(
new BottomTabBarStyle(
$r('sys.media.ohos_ic_public_clock'),
'任务'
)
)
TabContent() {
this.tabPage(
2,
'我的',
'手动点击 TabBar,观察业务索引是否同步。'
)
}
.tabBar(
new BottomTabBarStyle(
$r('sys.media.ohos_ic_public_clock'),
'我的'
)
)
}
.barPosition(BarPosition.End)
.vertical(false)
.scrollable(true)
.barOverlap(true)
.animationDuration(240)
.barFloatingStyle({
barWidth: {
smallWidth: 240,
mediumWidth: 320,
largeWidth: 400
},
barBottomMargin: 24
})
.onSelected((index: number) => {
this.pendingIndex = index;
this.transitionText =
`开始切换到:${this.getTabName(index)}`;
})
.onChange((index: number) => {
this.currentIndex = index;
this.pendingIndex = index;
this.transitionText =
`当前已显示:${this.getTabName(index)}`;
})
.width('100%')
.height('100%')
}
.width('100%')
.layoutWeight(1)
}
.width('100%')
.height('100%')
.padding({
left: 16,
right: 16,
top: 20
})
.backgroundColor('#F4F6FB')
}
}
更多推荐



所有评论(0)