[鸿蒙NEXT] 应用Tab栏导航条跟随 酷安案例实现

1,197 阅读6分钟

引言

Tab栏的自定义通过Builder自定义构建函数就可以实现大多数效果了,但想实现底部导航条跟随相对麻烦,这期分享一下通过鸿蒙原生提供的方法实现。

主要用到的两个关键点: SubTabBarStyleComponentContent

我们将结合实际代码,逐步拆解实现过程。

一、SubTabBarStyle的作用

SubTabBarStyle 是鸿蒙开发中用于自定义标签页样式的核心组件。它可以通过静态方法 of(content: ResourceStr | ComponentContent) 来定义标签栏的内容。其中,ComponentContent 提供了极高的灵活性,允许开发者自定义构建函数以控制标签页内容的展现。与只传入 ResourceStr 显示简单标题不同,ComponentContent 允许我们添加图片、动态文本等更加丰富的元素。

@Extend(TabContent)  //为了可读性更好,这里抽取成Extend,但其实没什么必要
function MyTabbar(context: UIContext, tabItem: Params) {
  .tabBar(SubTabBarStyle.of(new ComponentContent<Params>(context, wrapBuilder<[Params]>(buildText), tabItem))
     //SubTabBarStyle.of('首页')  这里可以直接传入ResourceStr类型
    .indicator({ //导航条属性
      color: Color.Green, 
      height: 4, 
      width: 24, 
      borderRadius: 4, 
      marginTop: 8 
    }))
}

上面展示了如何通过 SubTabBarStyle 实现自定义的标签样式。我们将一个 ComponentContent 对象传入 SubTabBarStyle.of(),并通过 indicator() 设置了标签下划线的样式,包括颜色、宽度、圆角半径以及下划线与文字的距离。

SubTabBarStyle 可以直接接收一个字符串类型参数作为标签内容,这也足够应付一些场景。但是在一些应用中,被选中的标签需要高亮展示,这个效果就需要自定义样式来条件判断了。

二、ComponentContent的使用

SubTabBarStyle.of()中不支持直接传入Builder自定义构建函数,这里就需要ComponentContent了。

官方描述:

ComponentContent表示组件内容的实体封装,其对象支持在非UI组件中创建与传递,便于开发者对弹窗类组件进行解耦封装。ComponentContent底层使用了BuilderNode,相关使用规格参考BuilderNode

听不懂思密达~

ComponentContent 是鸿蒙开发中用于封装组件内容的工具类,可以将复杂的组件传递给 SubTabBarStyle,实现更加复杂的UI交互逻辑。其主要构造函数 ComponentContent(uiContext, builder) 接受一个 UIContext 和一个 全局的Builder 函数,在其中定义具体的组件。

@Builder
function buildText(params: Params) {
  Column({ space: 4 }) {
    if (params.icon) {
      Image(params.icon).width(24).borderRadius(3)
    }
    Text(params.title).fontSize(14)
  }
  .width(60)
}

这个 Builder 函数和常用的自定义TabBar自定义差不多。 这里展示一个基础效果:

recording.gif

三、具体实现-酷安案例

最初想在这个导航条上花时间研究就是看到了酷安这个效果,并且这个效果其实应用的很广泛。

recording.gif

实现思路

我们的实现分为两个部分:

  1. Tabbar样式构建:通过SubTabBarStyle ComponentContent创建基本样式。
  2. ComponentContent抽取,update更新:因为ComponentContent只能接收全局builder,无法通过状态变量直接交互判断,所以通过update方法更新传参。
  3. 滑动事件处理:在页面滑动幅度超过一半时,目标页面对应标签就高亮显示。
1. Tabbar样式构建

这里通过提前准备的数据批量生成页面。

tabList: string[] =
  ['关注', '头条', '热榜', '快讯', '话题', '新机', '开箱', '摄影', '教程', '酷品', '汽车', '视频', '问答', '直播',
    '美化', '好物榜']
ForEach(this.tabList, (item: string, index) => {
  TabContent() {
    Column() {
      Text(`我是页面 ${item}`)
        .width('100%')
        .height('100%')
        .textAlign(TextAlign.Center)
        .backgroundColor(`#CD${Math.floor(Math.random() * 0x1000000).toString(16)}`)
    }.width('100%').height('100%').padding(16)
  }.tabBar(SubTabBarStyle.of(this.tabComList[index])  //第二步创建的ComponentContent
    .indicator({
      color: '#149A5A', // 下划线颜色
      height: 3, // 下划线高度
      width: 11, // 下划线宽度 b
      borderRadius: 4, // 下划线圆角半径
      marginTop: 6 // 下划线与文字间距
    }))
})
2. ComponentContent抽取

通过tab标签数据批量生成ComponentContent数组,以此创建对应标签页。

@State tabComList: ComponentContent<Params>[] = this.tabList.map((item, index) => {
  return new ComponentContent<Params>(this.context, wrapBuilder<[Params]>(buildText),
    { title: item, index: index, selectIndex: 0 })
})

ComponentContent传入的builder,按需传入对应参数。

interface Params {
  index: number,
  selectIndex: number,
  title: string
  icon?: ResourceStr
  icon_fill?: ResourceStr
}

@Builder
function buildText(params: Params) {
  Column({ space: 4 }) {
    if (params.icon) {
      Image(params.icon)
        .width(24)
        .borderRadius(3)
    }
    Text(params.title)
      .padding({ left: 4, right: 4 })
      .fontSize(params.index === params.selectIndex ? 17 : 16)
      .fontWeight(params.index === params.selectIndex ? FontWeight.Bold : FontWeight.Normal)
      .fontColor(params.index === params.selectIndex ? '#149A5A' : '#8C8C8C')
      .animation({ duration: 200 })
  }
}
3. 滑动事件处理

通过onGestureSwipe事件来获取当前滑动幅度(vp),当滑动超过屏幕宽度一半时高亮切换成下一个页签,onChange事件用于验证判断。(应该有更好的解决方式)

.onChange((index) => {
  this.tabComList[this.selectIndex].update({
    title: this.tabList[this.selectIndex],
    index: this.selectIndex,
    selectIndex: index
  })
  this.tabComList[index].update({ title: this.tabList[index], index: index, selectIndex: index })
  this.selectIndex = index
  this.isSwiping = false; // 滑动结束后重置状态
})
.onGestureSwipe((index, event) => {
  promptAction.showToast({ message: JSON.stringify(event, null, 2), bottom: 120 })
  const swipeThreshold = 180; // 定义滑动阈值

  let newIndex = this.selectIndex; // 初始化新索引

  // 判断滑动方向
  if (event.currentOffset > swipeThreshold && index !== 0) {
    newIndex = this.selectIndex - 1; // 向左滑动
  } else if (event.currentOffset < -swipeThreshold && index < this.tabList.length - 1) {
    newIndex = this.selectIndex + 1; // 向右滑动
  } else {
    newIndex = this.selectIndex
  }

  this.tabComList.forEach((item, index) => {
    item.update({
      title: this.tabList[index],
      index: index,
      selectIndex: newIndex
    });
  });
})

为了实现滑动时(在不松手状态下)切过去还能切回来,这里随滑动一直进行判断。

recording.gif

四、总结

其实实现起来感觉有点麻烦,而且有瑕疵。

Clip_2024-10-04_19-53-05.png

页面Demo代码:

import { ComponentContent, promptAction, UIContext } from "@kit.ArkUI"

interface Params {
  index: number,
  selectIndex: number,
  title: string
  icon?: ResourceStr
  icon_fill?: ResourceStr
}

@Extend(TabContent)
function MyTabbar(context: UIContext, tabItem: Params) {
  .tabBar(SubTabBarStyle.of(new ComponentContent<Params>(context, wrapBuilder<[Params]>(buildText),
    tabItem))
    .indicator({
      color: '#149A5A', // 下划线颜色
      height: 3, // 下划线高度
      width: 11, // 下划线宽度 b
      borderRadius: 4, // 下划线圆角半径
      marginTop: 6 // 下划线与文字间距
    }))
}

@Builder
function buildText(params: Params) {
  Column({ space: 4 }) {
    if (params.icon) {
      Image(params.icon)
        .width(24)
        .borderRadius(3)
    }
    Text(params.title)
      .padding({ left: 4, right: 4 })
      .fontSize(params.index === params.selectIndex ? 17 : 16)
      .fontWeight(params.index === params.selectIndex ? FontWeight.Bold : FontWeight.Normal)
      .fontColor(params.index === params.selectIndex ? '#149A5A' : '#8C8C8C')
      .animation({ duration: 200 })
  }
}

@Entry
@Component
struct FYYYTabs {
  private controller: TabsController = new TabsController();
  context: UIContext = this.getUIContext()
  @State selectIndex: number = 0
  tabList: string[] =
    ['关注', '头条', '热榜', '快讯', '话题', '新机', '开箱', '摄影', '教程', '酷品', '汽车', '视频', '问答', '直播',
      '美化', '好物榜']
  @State tabComList: ComponentContent<Params>[] = this.tabList.map((item, index) => {
    return new ComponentContent<Params>(this.context, wrapBuilder<[Params]>(buildText),
      { title: item, index: index, selectIndex: 0 })
  })
  @State isSwiping: boolean = false; // 状态标志

  build() {
    Column() {
      Tabs({ barPosition: BarPosition.End }) {

        ForEach(this.tabList, (item: string, index) => {
          TabContent() {
            Column() {
              Text(`我是页面 ${item}`)
                .width('100%')
                .height('100%')
                .textAlign(TextAlign.Center)
                .backgroundColor(`#CD${Math.floor(Math.random() * 0x1000000).toString(16)}`)
            }.width('100%').height('100%').padding(16)
          }.tabBar(SubTabBarStyle.of(this.tabComList[index])
            .indicator({
              color: '#149A5A', // 下划线颜色
              height: 3, // 下划线高度
              width: 11, // 下划线宽度 b
              borderRadius: 4, // 下划线圆角半径
              marginTop: 6 // 下划线与文字间距
            }))
        })
      }
      .barPosition(BarPosition.Start)
      .barBackgroundColor('#fff')
      .scrollable(true)
      .fadingEdge(false)
      .barMode(BarMode.Scrollable)
      .barHeight(80)
      .animationDuration(180)
      .backgroundColor(0xF5F5F5)
      .width('100%')
      .height('100%')
      .onChange((index) => {
        this.tabComList[this.selectIndex].update({
          title: this.tabList[this.selectIndex],
          index: this.selectIndex,
          selectIndex: index
        })
        this.tabComList[index].update({ title: this.tabList[index], index: index, selectIndex: index })
        this.selectIndex = index
        this.isSwiping = false; // 滑动结束后重置状态
      })
      .onGestureSwipe((index, event) => {
        promptAction.showToast({ message: JSON.stringify(event, null, 2), bottom: 120 })
        const swipeThreshold = 180; // 定义滑动阈值

        if (this.isSwiping) {
          // return;
        } // 如果正在滑动,则不处理

        let newIndex = this.selectIndex; // 初始化新索引

        // 判断滑动方向
        if (event.currentOffset > swipeThreshold && index !== 0) {
          newIndex = this.selectIndex - 1; // 向左滑动
        } else if (event.currentOffset < -swipeThreshold && index < this.tabList.length - 1) {
          newIndex = this.selectIndex + 1; // 向右滑动
        } else {
          newIndex = this.selectIndex
        }

        // 如果新索引发生变化,进行更新
        // if (newIndex !== this.selectIndex) {
        //   this.isSwiping = true; // 设置状态为正在滑动
        //   promptAction.showToast({ message: '已经变化', bottom: 120 });

        this.tabComList.forEach((item, index) => {
          item.update({
            title: this.tabList[index],
            index: index,
            selectIndex: newIndex
          });
        });
        // this.selectIndex = newIndex; // 更新当前选中的索引
        // }
      })

      // .onAnimationEnd((index) => {
      //   // promptAction.showToast({ message: '切换结束,索引为' + index, bottom: 120 })
      // })

    }.width('100%')
  }
}