小雨同学 2026-09-19 13:17:00 发布前言
任务列表里常见一个入口:用户在首页看到“查看待办”,点击以后直接进入任务页。页面已经采用 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@Componentstruct 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') }}暂无评论数据
发布
相关推荐
203
0
52
0
174
0
小雨同学
产品总监、独立开发者社群主理人、资深全栈工程师,HarmonyOS应用开发者高级认证,PMP认证,CSDN博客专家,鸿蒙极客,Trae Fellow,阿里云社区专家博主、51CTO 博客专家、OpenTiny 优秀布道师、科大讯飞荣誉讲师。
帖子
提问
粉丝
【HarmonyOS 7 悬浮页签深度实战】04 HdsTabsController 如何协调页签切换与显隐
2026-09-19 13:17:00 发布【HarmonyOS 7 悬浮页签深度实战】03 barFloatingStyle 的宽度、底部间距与遮罩如何配置
2026-09-18 12:32:32 发布


京公网安备:11010502051901号