前言

任务列表里常见一个入口:用户在首页看到“查看待办”,点击以后直接进入任务页。页面已经采用 HdsTabs 时,这次跳转不能再由业务代码模拟一次 TabBar 点击,也不适合同时维护一份脱离组件的选中状态。HdsTabsController 提供的 changeIndex() 可以发起切换,切换结果由 HdsTabs 的事件写回业务状态。

显隐也会遇到类似问题。进入专注阅读、全屏预览或临时操作状态后,页面可能需要收起底部页签栏,退出时恢复。当前 API 提供 applyHideAnimation()applyShowAnimation(),调用对象仍然是 HdsTabsController。这两个方法控制页签栏与 MiniBar 的显示隐藏动效,不会销毁 TabContent,也不会替业务层保存“为什么隐藏”这类页面状态。

项目里真正需要理清的是三份状态:HdsTabs 正在显示哪个页签,业务最近发出了什么切换或显隐命令,以及每个 TabContent 自己保存的数据。它们混在一个布尔值或一个索引里,外部按钮、用户点击和左右滑动逐渐增多以后,页面文字、业务状态与 TabBar 选中项就可能无法对应。

HdsTabsHdsTabsController 从 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;显式设置时长,方便观察 onSelectedonChange 的先后。240ms 只是一项实验值,正式项目要结合页面内容和实际操作手感调整。

用户手动点击“我的”以后,业务代码不需要额外编写同步逻辑。onSelected 收到 2,onChange 随后把 currentIndex 更新为 2。外部按钮调用 changeIndex(1) 时也会经过相同路径。入口不同,最终状态都从组件事件回到同一个字段,页面就不会出现“内容已经到了任务页,业务文字仍显示首页”的情况。

二、显隐命令控制底栏

页面顶部的“隐藏页签”和“恢复页签”按钮位于 HdsTabs 外部。这样页签栏收起后,恢复入口仍然可见。隐藏调用如下:

this.tabsController.applyHideAnimation(
  HdsAnimationMode.CLICK_ANIMATION
);

恢复时调用:

this.tabsController.applyShowAnimation(
  HdsAnimationMode.CLICK_ANIMATION
);

HdsAnimationMode 目前有 SCROLL_ANIMATIONCLICK_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 没有业务含义。项目可以保存稳定的页面标识,例如 hometaskprofile,构建出当前可见页签后换算成索引,并在调用 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
@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')
  }
}
Logo

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

更多推荐