自定义 Tabs组件

5 阅读8分钟

由于使用 Tabs 在滚动时,会默认选中的tab 会默认居中展示;同时底部的 indicator 滑动中不会存在平滑的移动效果;

实现效果如下:

录屏2026-07-15 16.56.12.gif

想要实现功能如下:

1.实现一个类似于Android 中 底部indicator 可以在tab 间平滑移动。

2.实现在选中tab 后,如果 tab 处于不完全可见时,会滚动到完全可见状态;

3.在从可见 tab 滑动到不可见 tab时

1)在不抬起手指的情况下,不可见 tab 跟随翻页实现比例滑动效果;

2)在缓慢滑动中松开手指情况下,不可见 tab 跟随翻页效果滑动可见;

3)在块石滑动时,不可见 tab 跟随翻页效果实现滑动到可见;

功能思路:

1.针对indicator 滑动,在 tab 间平滑移动; 由于原生不具备上述功能,那么就可以通过定义一个组件作为 indicator ,然后通过记录各个 tab 的位置信息。在滑动时让该 indicator组件实现移动的动画效果; 这样就解决了 indicator 移动问题;

2.针对不想 tab 选中后居中效果;那么就定义一个 Scroll + Row + ForEach(Title)的组合方式来代替 Tabs的 标签列表;同时设置 Tabs .barHeight(0)便签高度为0,这样就实现了偷梁换柱效果;

3.将 indicator 组件放在 步骤 2 中的组合组件下方;基本布局就实现了;

在滑动中需要用到的动画触发点:

1.在点击模拟 tab 标题 时,要选中指定的标签;indicator 滚动到该标签下;tabContent 滚动到指定位置;如果点击的tab 处于完全可见要滚动到完全可见位置;

2.在滑动 tabContent 时进行动画触发,情况分为如下:

1)跟随手指滑动时进行动画的移动就是在 Tabs .onGestureSwipe()方法中,实现 indicator的滚动效果; 如果 tab 处于不完全可见状态,根据比例实现 tab 标签滚动到可见位置的效果;

2)跟随手指移动中,松开手指的进行滚动;主要在Tabs .onAnimationStart(),onAnimationEnd() 方法中进行实现;实现indicator 移动到指定位置;如果标签不完全可见,就将tab 滚动到可见位置;

3)快速滑动时,在Tabs.onAnimationStart()方法中,如果滚动目标的位置不可见,就滚动到可见位置;

想要实现在滑动TabContent的时候,顶部的 tabs 跟随移动;

动作分为两种情况:

1.就是手指不离开屏幕滑动进行滑动,然后顶部标签进行滚动 ; 2.就是在滑动中手指松开进行滑动 ;

场景1:

1.在跟随手势滑动的时候,获取到滑动的距离;获取到 tabs 需要滚动的距离(tabs.width),通过当前滚动的距离/滚动总的宽度获取到对应比例 ratio; 通过 标签之间距离 * ratio 获取 需要移动的距离;

通过 this.Scroller. scrollTo()进行移动效果;x轴的偏移量 = 在原来滚动的基础上 + 当前需要滚动的比例;(注意这个地方需要区分滚动方向)

  //手势滚动到指定的 tabs
  gestureScrollTabs(index: number, currentOffset: number) {
    //判断滑动的目标标签是否可见,如果目标标签可见就无需滚动,如果不可见就进行滚动
    this.targetIndex = index
    //获取到目标的下标
    if (index > 0 && currentOffset > 0) {
      //向右滑动
      this.targetIndex--
    } else if (index < this.tabArray.length - 1 && currentOffset < 0) {
      this.targetIndex++
    }
    //获取目标的 view 是否可见
    this.scrollByLeft = this.textInfos[this.targetIndex].globalLeft
    this.scrollByRight = this.scrollByLeft + this.textInfos[this.targetIndex].width
    this.isOutSide = false
    //这个地方能够确定目标标签是否在屏幕外
    if (this.scrollByLeft < this.scroller.currentOffset().xOffset + this.scrollOffset ||
      (this.scrollByRight > this.scroller.currentOffset().xOffset + this.tabsWidth - this.scrollOffset)) {
      // 左边或者 右边 处于不可见
      this.isOutSide = true
    }
    //如果没有在界外就无需滚动
    if (!this.isOutSide) {
      return
    }

    this.tabsPreviousWidth = (this.scrollByLeft - this.indicatorMarginLeft)
    if (currentOffset > 0) {
      //向右滑动,获取到 标签左侧的距离,
      this.scroller.scrollTo({
        xOffset: this.scroller.currentOffset().xOffset +
          this.tabsPreviousWidth * (currentOffset / this.tabsWidth),
        yOffset: 0,
        animation: { duration: this.duration }
      })
    } else {
      //向左滑动
      this.scroller.scrollTo({
        xOffset: this.scroller.currentOffset().xOffset -
          this.tabsPreviousWidth * (currentOffset / this.tabsWidth),
        yOffset: 0,
        animation: { duration: this.duration }
      })
    }
  }
场景2

属于在手指松开手进行滚动; 这种只需要滚动到指定目标 的位置即可;这个地方注意滚动的位置;滚动的时间为 设置时间* 滚动位置/tabsWidth 来保证滚动的流程;

  this.scrollToVisible(this.currentPosition, this.duration * (Math.abs(event.currentOffset / this.tabsWidth)))
场景3

就是快速滑动然后松开,这种一定会滚动到下个位置;就需要将坐标变成targetIndex;

 this.scrollToVisible(targetIndex, this.duration)
  scrollToVisible(index: number, duration: number) {
    this.scrollLeft = this.textInfos[index].globalLeft
    this.scrollRight = this.scrollLeft + this.textInfos[index].width
    this.currentOffset = this.scroller.currentOffset().xOffset

    // console.info('-------- > scroll to visible ' + left + '.... current .... ' + currentOffset + " ... right " + right +
    //   " .. tabWidth " + this.tabsWidth)

    if (this.scrollLeft < this.currentOffset + this.scrollOffset) {
      this.scroller.scrollTo({
        xOffset: (this.scrollLeft - this.scrollOffset) <= 0 ? 0 : this.scrollLeft -= this.scrollOffset,
        yOffset: 0, animation: { duration: duration }
      })
    } else if (this.scrollRight > this.currentOffset + this.tabsWidth - this.scrollOffset) {
      this.scroller.scrollTo({
        xOffset: this.scrollRight - this.tabsWidth + this.scrollOffset,
        yOffset: 0,
        animation: { duration: duration }
      })
    }
  }

针对各个标签坐标信息的搜集

想要实现indicator 滚动到指定标签;以及 tab 滚动到可见位置。不可缺少的就是各个标签的坐标信息;标签信息的获取如下:

      private textInfos: (TextInfo)[] = []

      Text(title)
        .fontColor('#333')
        .fontSize(20)
        .onAreaChange((oldValue: Area, newValue: Area) => {
          //在这个地方获取到 标签的位置信息,以及宽度信息,以及下标信息
          if (this.textInfos[index]) {
            return
          }
          this.textInfos[index] =
            { globalLeft: newValue.globalPosition.x as number, width: newValue.width as number, index: index }
          if (this.currentPosition == index && !this.isStartAnimateTo) {
            //获取到当前的位置信息
            this.indicatorWidth = this.textInfos[index].width
            this.indicatorMarginLeft = this.textInfos[index].globalLeft
            this.currentPosition = index
          }
        })
interface TextInfo {
  globalLeft: number
  width: number
  index: number
}

注意:由于滚动中 对应标签的位置会发生改变。隐藏在初始化一次后。就避免在滚动中进行继续更改坐标点;

根据坐标信息以及滚动的偏移量获取到对应的位置信息

在滚动过程中 针对indicator 的移动情况需要实时更新。那么就需要通过TabContent 的移动偏移量来获取到 indicator 对应的坐标信息;其实就是通过 TabContent 滚动距离占tabsWidth 宽度的比例;获取到对应 indicator 移动的距离;代码如下:

  //获取到当前坐标的下标信息
  getCurrentIndexInfo(index: number, event: TabsAnimationEvent): TextInfo {
    let nextIndex = index
    if (index > 0 && event.currentOffset > 0) {
      //向右滑动
      nextIndex--
    } else if (index < this.tabArray.length - 1 && event.currentOffset < 0) {
      //向左滑动
      nextIndex++
    }
    //获取到移动的比例
    let radio = Math.abs(event.currentOffset / this.tabsWidth)
    //获取到新的 left 和width,以及 index
    let currentIndex = radio > 0.5 ? nextIndex : index

    let currentLeft = this.textInfos[index].globalLeft +
      (this.textInfos[nextIndex].globalLeft - this.textInfos[index].globalLeft) * radio

    let currentWidth =
      this.textInfos[index].width + (this.textInfos[nextIndex].width - this.textInfos[index].width) * radio;

    return { globalLeft: currentLeft, width: currentWidth, index: currentIndex }
  }

在松开手时实现 indicator 滚动到指定位置代码如下:

  //滚动到指定位置;
  scrollerToAnimal(duration: number, left: number, width: number) {
    this.isStartAnimateTo = true
    this.getUIContext().animateTo({
      curve: Curve.Linear,
      duration: duration,
      playMode: PlayMode.Normal,
      iterations: 1,
      onFinish: () => {
        this.isStartAnimateTo = false
      }
    }, () => {
      this.indicatorWidth = width
      this.indicatorMarginLeft = left
    })
  }

获取到TabWidth:

在 Tabs.onAreaChange()方法中获取到Tabs 的宽度如下:

    .onAreaChange((oldValue: Area, newValue: Area) => {
        //获取到 tabs 的宽度,
        this.tabsWidth = newValue.width as number
      })

整体代码如下:

代码没有进行整体的抽取封装;如果需要可以直接抽取为一个自定义组件类;

  private scroller: Scroller = new Scroller();
  private tabArray: TabItemInfo[] = [
    { name: '推荐' }, { name: '关注' }, { name: '热门' },
    { name: '军事' }, { name: '体育' }, { name: '八卦' },
    { name: '数码' }, { name: '财经' }, { name: '美食' }, { name: '旅行' }
  ]
  private info: TextInfo | null = null
  private targetIndex: number = 0
  private scrollByLeft: number = 0
  private scrollByRight: number = 0
  private isOutSide: boolean = false
  private animal: AnimatorResult | null = null
  //是否跳转到下一个页面
  private isSkipNextPage: boolean = false
  private tabsPreviousWidth: number = 0
  @Local indicatorWidth: number = 0
  @Local indicatorMarginLeft: number = 0
  @Local tabsWidth: number = 0
  private currentPosition: number = 0
  private isStartAnimateTo: boolean = false
  private duration: number = 300
  private textInfos: (TextInfo)[] = []
  private tabsControl: TabsController = new TabsController()
  private common: CommonModifier = new CommonModifier()

  resetConfig() {
    this.targetIndex = 0
    this.scrollByLeft = 0
    this.scrollByRight = 0
    this.isOutSide = false
    this.isSkipNextPage = false
    this.tabsPreviousWidth = 0
  }


  @Builder
  scrolledIndicatorBuilder() {
    Column() {
      Scroll(this.scroller) {
        Stack({ alignContent: Alignment.TopStart }) {
          Row() {
            ForEach(this.tabArray, (item: TabItemInfo, index: number) => {
              this.scrollIndicator(item.name, index, 'scroll')
            })
          }

          Column()
            .width(this.indicatorWidth)
            .margin({ left: this.indicatorMarginLeft, top: 40 })
            .backgroundColor(Color.Blue)
            .height(2)
            .borderRadius(1)
        }
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .edgeEffect(EdgeEffect.Fade)
      .height(58)
      .align(Alignment.Start)
      .friction(0.6)

      Tabs({
        barPosition: BarPosition.Start,
        index: 0,
        controller: this.tabsControl,
        barModifier: this.common
      }) {
        ForEach(this.tabArray, (item: TabItemInfo, index: number) => {
          //生成对应的 数据
          TabContent() {
            Column() {
              Text(item.name)
                .fontSize(20)
                .fontColor("#333")
            }
            .background(Color.Gray)
            .width('100%')
            .height('100%')
            .alignItems(HorizontalAlign.Center)
            .justifyContent(FlexAlign.Center)

          }.tabBar(this.scrollIndicator(item.name, index, 'tabs '))
        })
      }
      .vertical(false)
      .barHeight(0)
      .onAreaChange((oldValue: Area, newValue: Area) => {
        //获取到 tabs 的宽度,
        this.tabsWidth = newValue.width as number
      })
      .layoutWeight(1)
      .scrollable(true)
      .barMode(BarMode.Scrollable)
      .scrollable(true)
      .edgeEffect(EdgeEffect.Fade)
      .onChange((num) => {
        this.currentPosition = num
      })
      .onSelected((number) => {
        // console.info(
        //   `------------- > onArea gestureAnimTabs onSelected  ${number}  `)
        this.isSkipNextPage = true
      })
      .onAnimationStart((index: number, targetIndex: number, event: TabsAnimationEvent) => {
        //在松手的时候indicator 滚动
        this.scrollerToAnimal(this.duration, this.textInfos[targetIndex].globalLeft, this.textInfos[targetIndex].width)
        //tab 不完全可见,实现标签滚动到可见位置
        if (this.isSkipNextPage) {
          //标签滚动到指定位置,如果标签处于可见或者不可见时;
          if (Math.abs(event.velocity) > 200) {
            //为啥快速滑动,然后松开手指时,指定该方法 无法滚动到指定位置呢
            this.scrollToVisible(targetIndex, this.duration)
          } else {
            this.scrollToVisible(this.currentPosition, this.duration * (Math.abs(event.currentOffset / this.tabsWidth)))
          }
        }
      })
      .onAnimationEnd((index: number, event: TabsAnimationEvent) => {
        console.info(`------------- > onArea gestureAnimTabs onAnimationEnd `)
        //在动画结束的时候获取到目标的位置,然后进行滚动
        let info = this.getCurrentIndexInfo(index, event)
        this.scrollerToAnimal(0, info.globalLeft, info.width)
        //标签滚动到指定位置,如果标签处于可见或者不可见时;
        this.scrollToVisible(this.currentPosition, this.duration)
        //重置参数
        this.resetConfig()

      })
      .onGestureSwipe((index: number, event: TabsAnimationEvent) => {
        //在跟随手指移动中,获取到要移动的位置信息;然后获取到对应的坐标,并获取到对应的 数据
        this.info = this.getCurrentIndexInfo(index, event)
        this.currentPosition = this.info.index
        this.indicatorMarginLeft = this.info.globalLeft
        this.indicatorWidth = this.info.width
        //进行滚动处理
        this.gestureScrollTabs(index, event.currentOffset)
      })
    }
    .width('100%')
    .height('100%')
  }

  aboutToDisappear(): void {
    this.animal?.cancel()
    this.animal = null
  }

  //手势滚动到指定的 tabs
  gestureScrollTabs(index: number, currentOffset: number) {
    //判断滑动的目标标签是否可见,如果目标标签可见就无需滚动,如果不可见就进行滚动
    this.targetIndex = index
    //获取到目标的下标
    if (index > 0 && currentOffset > 0) {
      //向右滑动
      this.targetIndex--
    } else if (index < this.tabArray.length - 1 && currentOffset < 0) {
      this.targetIndex++
    }
    //获取目标的 view 是否可见
    this.scrollByLeft = this.textInfos[this.targetIndex].globalLeft
    this.scrollByRight = this.scrollByLeft + this.textInfos[this.targetIndex].width
    this.isOutSide = false
    //这个地方能够确定目标标签是否在屏幕外
    if (this.scrollByLeft < this.scroller.currentOffset().xOffset + this.scrollOffset ||
      (this.scrollByRight > this.scroller.currentOffset().xOffset + this.tabsWidth - this.scrollOffset)) {
      // 左边或者 右边 处于不可见
      this.isOutSide = true
    }
    //如果没有在界外就无需滚动
    if (!this.isOutSide) {
      return
    }

    this.tabsPreviousWidth = (this.scrollByLeft - this.indicatorMarginLeft)
    if (currentOffset > 0) {
      //向右滑动,获取到 标签左侧的距离,
      this.scroller.scrollTo({
        xOffset: this.scroller.currentOffset().xOffset +
          this.tabsPreviousWidth * (currentOffset / this.tabsWidth),
        yOffset: 0,
        animation: { duration: this.duration }
      })
    } else {
      //向左滑动
      this.scroller.scrollTo({
        xOffset: this.scroller.currentOffset().xOffset -
          this.tabsPreviousWidth * (currentOffset / this.tabsWidth),
        yOffset: 0,
        animation: { duration: this.duration }
      })
    }
  }

  //获取到当前坐标的下标信息
  getCurrentIndexInfo(index: number, event: TabsAnimationEvent): TextInfo {
    let nextIndex = index
    if (index > 0 && event.currentOffset > 0) {
      //向右滑动
      nextIndex--
    } else if (index < this.tabArray.length - 1 && event.currentOffset < 0) {
      //向左滑动
      nextIndex++
    }
    //获取到移动的比例
    let radio = Math.abs(event.currentOffset / this.tabsWidth)
    //获取到新的 left 和width,以及 index
    let currentIndex = radio > 0.5 ? nextIndex : index

    let currentLeft = this.textInfos[index].globalLeft +
      (this.textInfos[nextIndex].globalLeft - this.textInfos[index].globalLeft) * radio

    let currentWidth =
      this.textInfos[index].width + (this.textInfos[nextIndex].width - this.textInfos[index].width) * radio;

    return { globalLeft: currentLeft, width: currentWidth, index: currentIndex }
  }

  @Builder
  scrollIndicator(title: string, index: number, from: string) {
    Column() {
      Text(title)
        .fontColor('#333')
        .fontSize(20)
        .onAreaChange((oldValue: Area, newValue: Area) => {
          //在这个地方获取到 标签的位置信息,以及宽度信息,以及下标信息
          if (this.textInfos[index]) {
            return
          }
          this.textInfos[index] =
            { globalLeft: newValue.globalPosition.x as number, width: newValue.width as number, index: index }
          if (this.currentPosition == index && !this.isStartAnimateTo) {
            //获取到当前的位置信息
            this.indicatorWidth = this.textInfos[index].width
            this.indicatorMarginLeft = this.textInfos[index].globalLeft
            this.currentPosition = index
          }
        })
        .onClick(() => {
          //在点击时 tabs 滚动到指定的卡片
          this.tabsControl.changeIndex(index)
          //需要获取到当前的位置
          this.currentPosition = index
          //点击时需要获取到位置,实现指示器的滚动
          this.scrollToVisible(this.currentPosition, this.duration)

        })
    }
    .alignItems(HorizontalAlign.Center)
    .padding({
      left: 20,
      right: 20,
      top: 10,
      bottom: 10
    })
  }

  private scrollOffset: number = 20
  private scrollLeft: number = 0
  private scrollRight: number = 0
  private currentOffset: number = 0

  scrollToVisible(index: number, duration: number) {
    this.scrollLeft = this.textInfos[index].globalLeft
    this.scrollRight = this.scrollLeft + this.textInfos[index].width
    this.currentOffset = this.scroller.currentOffset().xOffset

    // console.info('-------- > scroll to visible ' + left + '.... current .... ' + currentOffset + " ... right " + right +
    //   " .. tabWidth " + this.tabsWidth)

    if (this.scrollLeft < this.currentOffset + this.scrollOffset) {
      this.scroller.scrollTo({
        xOffset: (this.scrollLeft - this.scrollOffset) <= 0 ? 0 : this.scrollLeft -= this.scrollOffset,
        yOffset: 0, animation: { duration: duration }
      })
    } else if (this.scrollRight > this.currentOffset + this.tabsWidth - this.scrollOffset) {
      this.scroller.scrollTo({
        xOffset: this.scrollRight - this.tabsWidth + this.scrollOffset,
        yOffset: 0,
        animation: { duration: duration }
      })
    }
  }

  //滚动到指定位置;
  scrollerToAnimal(duration: number, left: number, width: number) {
    this.isStartAnimateTo = true
    this.getUIContext().animateTo({
      curve: Curve.Linear,
      duration: duration,
      playMode: PlayMode.Normal,
      iterations: 1,
      onFinish: () => {
        this.isStartAnimateTo = false
      }
    }, () => {
      this.indicatorWidth = width
      this.indicatorMarginLeft = left
    })
  }