积分中心
【HarmonyOS 7 悬浮页签深度实战】04 HdsTabsController 如何协调页签切换与显隐 原创
头像 小雨同学 2026-09-19 13:17:00    发布
1745 浏览 8 点赞 0 收藏

前言

任务列表里常见一个入口:用户在首页看到“查看待办”,点击以后直接进入任务页。页面已经采用 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。
  2. 点击顶部“去任务页”,观察切换目标变成任务,完成后当前索引变为 1。
  3. 在任务页记录一次操作,然后手动点击“我的”,确认 onChange 把当前索引更新为 2。
  4. 点击“隐藏页签”,观察 TabBar 隐藏时内容页面仍然保留,顶部恢复按钮可以继续使用。
  5. 点击“恢复页签”,确认选中项仍为“我的”,三个页面的操作次数没有归零。

出现状态不一致时,可以按现象缩小范围:


页面现象优先检查
外部按钮没有切换页面控制器是否绑定当前 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')  }}


©本站发布的所有内容,包括但不限于文字、图片、音频、视频、图表、标志、标识、广告、商标、商号、域名、软件、程序等,除特别标明外,均来源于网络或用户投稿,版权归原作者或原出处所有。我们致力于保护原作者版权,若涉及版权问题,请及时联系我们进行处理。
分类
HarmonyOS

暂无评论数据

加载中...

发布

地址:北京市朝阳区北三环东路三元桥曙光西里甲1号第三置业A座1508室 电话:13391790444或(010)62178877
版权所有:电脑商情信息服务集团 北京赢邦策略咨询有限责任公司
声明:本媒体部分图片、文章来源于网络,版权归原作者所有,我司致力于保护作者版权,如有侵权,请与我司联系删除

京ICP备:2022009079号-2

京公网安备:11010502051901号

ICP证:京B2-20230255