场景描述

在页面布局过程中,Tabs可以将产品包含的所有内容进行清晰分类,一目了然地呈现应用的内容范围,方便概览与跳转

  • 场景一:tab嵌套list的吸顶效果
  • 场景二:tabbar样式自定义:

      1. tabs切换、监听
      1. 样式自定义
      1. tabbar尾端文字渐变
  • 场景三:tabContent切换动画

方案描述

场景一:tab嵌套list的吸顶效果

方案一:

实现思路:

1、最外层为tabs组件,首页tabContent主要用的stack组件嵌套了scroll组件+导航输入框组件,其中scroll组件嵌套了tabs组件,tabs里面嵌套list组件。

2、外层的滚动组件scroll主要通过onScroll,onScrollEdge以及onScrollFrameBegin回调判断页面是否在顶部,中间还是底部。

3、里层list组件也是通过onReachStart,onReachEnd,onScrollFrameBegin回调来判断list列表是否在顶部,中间还是底部,使用scrollBy滑动指定距离。如Scroll嵌套List滚动时,List组件的edgeEffect属性需设置为EdgeEffect.None。

核心代码

// scroll部分主要逻辑

enum ScrollPosition{
  start,
  center,
  end
}

@Entry
@Component
struct NestedScroll {
  @State listPosition: number = ScrollPosition.start; // 0代表滚动到List顶部,1代表中间值,2代表滚动到List底部。
  @State scrollPosition: number = ScrollPosition.start; // 0代表滚动到页面顶部,1代表中间值,2代表滚动到页面底部。
  ...

  build() {
    Column() {
      Tabs({ barPosition: BarPosition.End, index: this.currentIndex, controller: this.TabsController }) {
        TabContent() {
          Stack({ alignContent: Alignment.Top }) {
            Scroll(this.scrollerForScroll) {
              Column() {
                Column(){

                }
                .width("100%")
                .height("40%")
                .backgroundColor(Color.Pink)


                // tabbar
                Row({ space: 7 }) {
                  Scroll() {
                    ...
                  }
                }

                //tabs
                Tabs({ barPosition: BarPosition.Start, controller: this.subsController }) {
                  TabContent() {
                    List({ space: 10, scroller: this.scrollerForList }) {
                      ...
                    }
                    .onReachStart(() => {
                      this.listPosition = ScrollPosition.start
                    })
                    .onReachEnd(() => {
                      this.listPosition = ScrollPosition.end
                    })
                    .onScrollFrameBegin((offset: number, state: ScrollState) => {

                      console.info('chenoffset::'+offset)
                      // 滑动到列表中间时
                      if (!((this.listPosition == ScrollPosition.start && offset < 0)
                        || (this.listPosition == ScrollPosition.end && offset > 0))) {
                        this.listPosition = ScrollPosition.center
                      }

                      // 如果页面已滚动到底部 且 列表不在顶部或列表有正向偏移量
                      if (this.scrollPosition == ScrollPosition.end
                        && (this.listPosition != ScrollPosition.start || offset > 0)) {
                        console.info('chenoffsetscrollBy::'+offset)
                        return { offsetRemain: offset };
                      } else {
                        // scrollBy滑动指定距离
                        console.info('chenoffsetscrollBy滑动指定距离::'+offset)
                        this.scrollerForScroll.scrollBy(0, offset)
                        return { offsetRemain: 0 };
                      }
                    })
                  }.tabBar('关注')
                  ...
                }
              }
              .width("100%")
              .height("92%")
              .backgroundColor('#F1F3F5')

            }
          }
          .scrollBar(BarState.Off)
          .width("100%")
          .height("100%")
          // 滚动事件回调, 返回竖直方向偏移量,单位vp
          .onScroll((xOffset: number, yOffset: number) => {
            this.currentYOffset = this.scrollerForScroll.currentOffset().yOffset;
            console.info('this.currentYOffset'+this.currentYOffset)
            // 非(页面在顶部或页面在底部),则页面在中间
            if (!((this.scrollPosition == ScrollPosition.start && yOffset < 0)
              || (this.scrollPosition == ScrollPosition.end && yOffset > 0))) {
              this.scrollPosition = ScrollPosition.center
            }
          })
          // 当组件滚动到边缘时触发
          .onScrollEdge((side: Edge) => {
            if (side == Edge.Top) {
              // 页面在顶部
              this.scrollPosition = ScrollPosition.start
            } else if (side == Edge.Bottom) {
              // 页面在底部
              this.scrollPosition = ScrollPosition.end
            }
          })
          .onScrollFrameBegin(offset => {
            if (this.scrollPosition == ScrollPosition.end) {
              return { offsetRemain: 0 };
            } else {
              return { offsetRemain: offset };
            }
          })

          // 顶部导航输入框
          Row() {
            TextInput({ text: '', placeholder: 'input your word...', controller: this.controller }).fontSize(24)
          }
          .justifyContent(FlexAlign.Center)
          .backgroundColor('#00ffffff')
          .width('100%')
          .height('8%')

        }

      }.tabBar(this.tabBuilder(0, '首页'))

      ...
    }
    ...
  }.width('100%')
}
}

方案二:

通过原生属性nestedScroll,结合calc计算高度实现上述效果

核心代码

Tabs({ barPosition: BarPosition.Start, controller: this.subsController }) {
  TabContent() {
    List({ space: 10, scroller: this.scrollerForList }) {
      ...
    }
    .nestedScroll({
      scrollForward: NestedScrollMode.PARENT_FIRST,
      scrollBackward: NestedScrollMode.SELF_FIRST
    })

场景二:tabbar样式自定义

方案

由于tabs本身是有组件进行封装的,如果需要自定义样式,可以使用swiper自定义实现,Swiper在能力演进上会比Tabs能力强,比如使用swiper自定义的tabs组件可以实现数据懒加载功能

通过swiper实现tabs以下功能点:

1、下划线跟手动画:通过swiper的onGestureSwipe在页面跟手滑动过程中的回调,返回index以及extraInfo动画相关信息来判断当前index、页签距离左边margin,以及当前页签的宽度信息等,再利用动画开始以及动画结束回调结合animateTo实现下划线的动效。

2、tabbar 选中文字颜色变化:判断是否为currentIndex设置为不一样的文字颜色。

3、tabbar 选中页签位置居中:用scroll+row自定义页签栏,通过scroll实现页签停留位置居中效果。

4、使用图像效果blendMode,将当前控件的内容与下方画布已有内容进行混合,给自定义tabbar的组件row设置.blendMode,给row的父组件设置linearGradient以及blendMode来实现文字尾端渐变效果。

关于blendMode枚举说明,s表示源像素,d表示目标像素,sa表示原像素透明度,da表示目标像素透明度,r表示混合后像素,ra表示混合后像素透明度。

BlendMode.SRC\_IN:r = s * da,只显示源像素中与目标像素重叠的部分。

BlendMode.SRC\_OVER:r = s * (1 - da),只显示源像素中与目标像素不重叠的部分。

BlendApplyType.OFFSCREEN:将此组件和子组件内容绘制到离屏画布上,然后整体进行混合

核心代码

第一步:通过scroll组件+row组件实现自定义可滑动的tabbar

Row(){
  Column() {
    Scroll(this.scroller) {
      Row() {
        ForEach(this.arr, (item: string, index: number) => {
          Column() {
            Text(item)
              .fontSize(16)
              .borderRadius(5)
                //字体颜色粗细变化
              .fontColor(this.indicatorIndex === index ? Color.Red : Color.Black)
              .fontWeight(this.indicatorIndex === index ? FontWeight.Bold : FontWeight.Normal)
              .margin({ left: this.initialTabMargin, right: this.initialTabMargin })
              .id(index.toString())
              .onAreaChange((oldValue: Area, newValue: Area) => {
                if (this.indicatorIndex === index && (this.indicatorMarginLeft === 0 || this.indicatorWidth === 0)) {
                  if (newValue.globalPosition.x != undefined) {
                    let positionX = Number.parseFloat(newValue.globalPosition.x.toString());
                    this.indicatorMarginLeft = Number.isNaN(positionX) ? 0 : positionX;
                  }
                  let width = Number.parseFloat(newValue.width.toString());
                  this.indicatorWidth = Number.isNaN(width) ? 0 : width;
                }
              })
              .onClick(() => {
                this.indicatorIndex = index;
                this.underlineScrollAuto(this.animationDuration, index);
                this.scrollIntoView(index);
                // swiper进行联动
                this.swiperIndex = index;
              })
          }
          .width(this.textLength[index] * 28)
        }, (item: string) => item)
      }
      .height(32)

    }
    .width('100%')
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
    .edgeEffect(EdgeEffect.Spring)
    .onScroll((xOffset: number, yOffset: number) => {
      console.info(xOffset + ' ' + yOffset)
      this.indicatorMarginLeft -= xOffset;
    })
    .onScrollStop(() => {
      console.info('Scroll Stop')
      this.underlineScrollAuto(0, this.indicatorIndex);
    })
    //下划线
    Column()
      .width(this.indicatorWidth)
      .height(2)
      .borderRadius(2)
      .backgroundColor(Color.Red)
      .alignSelf(ItemAlign.Start)
      .margin({ left: this.indicatorMarginLeft, top: 5 })
  }
  .width('92%')
  .margin({ top: 15, bottom: 10})

  Text('更多')
    .width(36)
    .height(50)
    .backgroundColor(Color.Pink)
    .fontSize(16)
    .borderRadius(5)
}

第二步:通过swiper组件来写tabContent对应的区域,主要用swiper的属性index(this.swiperIndex)来联动上面的自定义tabbar,swiper里面可以使用LazyForEach来实现数据懒加载功能

Swiper(this.swiperController) {
  LazyForEach(this.data, (item: number) => {
    Column() {
      Text(item.toString())
      ...
    }
    .onAreaChange((oldValue: Area, newValue: Area) => {
      let width = Number.parseFloat(newValue.width.toString());
      this.swiperWidth = Number.isNaN(width) ? 0 : width;
    })
  }, (item: string) => item)
}
.onChange((index: number)=>{
  this.swiperIndex = index;
})
.cachedCount(2)
.index(this.swiperIndex)
.indicator(false)
.curve(this.animationCurve)
.loop(false)

第三步:1、通过swiper的onGestureSwipe,实现跟手过程中是左滑还是右滑,计算当前以及下一个目标页面的索引值,当前距离左边的距离,以及当前tabbar的宽度2、通过用componentUtils.getRectangleById,获取指定id的组件大小、位置、平移缩放旋转及仿射矩阵属性信息,得到当前距离左边的距离以及对应tabbar的宽度,用onAnimationStart在切换动画开始触发的时候,下划线跟踪页面一起滑动,同时宽度渐变,3、当滑动结束时通过onAnimationEnd以及自定义tabbar的scrollTo等回调实现tabbar在滚动结束之后再中间位置

.onAnimationStart((index: number, targetIndex: number, event: SwiperAnimationEvent) => {
  // 切换动画开始时触发该回调。下划线跟着页面一起滑动,同时宽度渐变。
  this.indicatorIndex = targetIndex;
  this.underlineScrollAuto(this.animationDuration, targetIndex);
})
  .onAnimationEnd((index: number, event: SwiperAnimationEvent) => {
    // 切换动画结束时触发该回调。下划线动画停止。
    let currentIndicatorInfo = this.getCurrentIndicatorInfo(index, event);
    this.startAnimateTo(0, currentIndicatorInfo.left, currentIndicatorInfo.width);
    this.scrollIntoView(index);
  })
  .onGestureSwipe((index: number, event: SwiperAnimationEvent) => {
    // 在页面跟手滑动过程中,逐帧触发该回调。
    let currentIndicatorInfo = this.getCurrentIndicatorInfo(index, event);
    this.indicatorIndex = currentIndicatorInfo.index;//当前页签index
    this.indicatorMarginLeft = currentIndicatorInfo.left;//当前页签距离左边margin
    this.indicatorWidth = currentIndicatorInfo.width;//当前页签宽度
  })
// 获取屏幕宽度,单位vp
private getDisplayWidth(): number {
  return this.displayInfo != null ? px2vp(this.displayInfo.width) : 0;
}

// 获取组件大小、位置、平移缩放旋转及仿射矩阵属性信息。
private getTextInfo(index: number): Record<string, number> {
  let modePosition :componentUtils.ComponentInfo = componentUtils.getRectangleById(index.toString());
  try {
  return { 'left': px2vp(modePosition.windowOffset.x), 'width': px2vp(modePosition.size.width) }
} catch (error) {
  return { 'left': 0, 'width': 0 }
}
}

// 当前下划线动画
private getCurrentIndicatorInfo(index: number, event: SwiperAnimationEvent): Record<string, number> {
  let nextIndex = index;
  // 滑动范围限制,Swiper不可循环,Scroll保持不可循环
  if (index > 0 && event.currentOffset > 0) {
  nextIndex--; // 左滑
} else if (index < this.data.totalCount() - 1 && event.currentOffset < 0) {
  nextIndex++; // 右滑
}
this.nextIndicatorIndex = nextIndex;
// 获取当前tabbar的属性信息
let indexInfo = this.getTextInfo(index);
// 获取目标tabbar的属性信息
let nextIndexInfo = this.getTextInfo(nextIndex);
// 滑动页面超过一半时页面切换
this.swipeRatio = Math.abs(event.currentOffset / this.swiperWidth);
let currentIndex = this.swipeRatio > 0.5 ? nextIndex : index; // 页面滑动超过一半,tabBar切换到下一页。
let currentLeft = indexInfo.left + (nextIndexInfo.left - indexInfo.left) * this.swipeRatio;
let currentWidth = indexInfo.width + (nextIndexInfo.width - indexInfo.width) * this.swipeRatio;
this.indicatorIndex = currentIndex;
return { 'index': currentIndex, 'left': currentLeft, 'width': currentWidth };
}


private scrollIntoView(currentIndex: number): void {
  const indexInfo = this.getTextInfo(currentIndex);
  let tabPositionLeft = indexInfo.left;
  let tabWidth = indexInfo.width;
  // 获取屏幕宽度,单位vp
  const screenWidth = this.getDisplayWidth();
  const currentOffsetX: number = this.scroller.currentOffset().xOffset;//当前滚动的偏移量

  this.scroller.scrollTo({
                         // 将tabbar可滑动时候定位在正中间
                           xOffset: currentOffsetX + tabPositionLeft - screenWidth / 2 + tabWidth / 2,
                           yOffset: 0,
                           animation: {
                             duration: this.animationDuration,
                             curve: this.animationCurve, // 动画曲线
                           }
                         });
  this.underlineScrollAuto(this.animationDuration, currentIndex);
}

private startAnimateTo(duration: number, marginLeft: number, width: number): void {
  animateTo({
  duration: duration, // 动画时长
  curve: this.animationCurve, // 动画曲线
  onFinish: () => {
    console.info('play end')
  }
}, () => {
  this.indicatorMarginLeft = marginLeft;
  this.indicatorWidth = width;
})
}

// 下划线动画
private underlineScrollAuto(duration: number, index: number): void {
  let indexInfo = this.getTextInfo(index);
  this.startAnimateTo(duration, indexInfo.left, indexInfo.width);
}

第四步:使用图像效果blendMode以及颜色渐变linearGradient实现文字尾端有渐变的效果

Scroll(this.scroller) {
  Row() {
    ForEach(this.arr, (item: string, index: number) => {
      ...
    }, (item: string) => item)
  }
  .blendMode(BlendMode.SRC_IN, BlendApplyType.OFFSCREEN)
  .backgroundColor(Color.Transparent)
  .height(32)
}
// 设置tabbar文字尾端显隐
.linearGradient({
  angle: 90,
  colors: [['rgba(0, 0, 0, 0)', 0], ['rgba(0, 0, 0, 1)', 0], ['rgba(0, 0, 0, 1)', 0.9], ['rgba(0, 0, 0, 0)', 1]]
})
.blendMode(BlendMode.SRC_OVER, BlendApplyType.OFFSCREEN)

场景三:tabContent切换动画

方案

通过customContentTransition实现了自定义Tabs页面的切换动画,index0-1,2-3是缩放,其他页面切换时显隐from:动画开始时,当前页面的index值。to:动画开始时,目标页面的index值。使用customContentTransition注意事项:1、当使用自定义切换动画时,Tabs组件自带的默认切换动画会被禁用,同时,页面也无法跟手滑动。2、当设置为undefined时,表示不使用自定义切换动画,仍然使用组件自带的默认切换动画。3、当前自定义切换动画不支持打断。4、目前自定义切换动画只支持两种场景触发:点击页签和调用TabsController.changeIndex()接口。

核心代码

// 自定义tabContent切换效果
// customContentTransition 控制是否为undefined
@State useCustomAnimation: boolean = true
// tabContent对应内容区域缩放值
@State tabContent0Scale: number = 1.0
@State tabContent1Scale: number = 1.0
@State tabContent2Scale: number = 1.0
@State tabContent3Scale: number = 1.0
// tabContent对应内容区域显隐值
@State tabContent0Opacity: number = 1.0
@State tabContent1Opacity: number = 1.0
@State tabContent2Opacity: number = 1.0
@State tabContent3Opacity: number = 1.0

private firstTimeout: number = 1000
private secondTimeout: number = 1000
private first2secondDuration: number = 2000
private second2thirdDuration: number = 2000
private first2thirdDuration: number = 2000
// - from:动画开始时,当前页面的index值。
// - to:动画开始时,目标页面的index值。
private baseCustomAnimation: (from: number, to: number) => TabContentAnimatedTransition = (from: number, to: number) => {
  if ((from === 0 && to === 1) || (from === 1 && to === 0)|| (from === 2 && to === 3)||(from ===3 && to === 2)) {
    // 缩放动画
    let firstCustomTransition = {
      timeout: this.firstTimeout,
      transition: (proxy: TabContentTransitionProxy) => {
        if (proxy.from === 0 && proxy.to === 1) {
          this.tabContent0Scale = 1.0
          this.tabContent1Scale = 0.5
        } else {
          this.tabContent0Scale = 0.5
          this.tabContent1Scale = 1.0
        }

        if (proxy.from === 2 && proxy.to === 3) {
          this.tabContent2Scale = 1.0
          this.tabContent3Scale = 0.5
          this.tabContent3Opacity = 1.0
        } else {
          this.tabContent2Scale = 0.5
          this.tabContent3Scale = 1.0
          this.tabContent2Opacity = 1.0 //透明度
        }

        animateTo({
          duration: this.first2secondDuration,
          onFinish: () => {
            proxy.finishTransition()
          }
        }, () => {
          if (proxy.from === 0 && proxy.to === 1) {
            this.tabContent0Scale = 0.5
            this.tabContent1Scale = 1.0
          } else {
            this.tabContent0Scale = 1.0
            this.tabContent1Scale = 0.5
          }
          if (proxy.from === 2 && proxy.to === 3) {
            this.tabContent2Scale = 0.5
            this.tabContent3Scale = 1.0
            this.tabContent2Opacity = 1.0 //透明度
          } else {
            this.tabContent2Scale = 1.0
            this.tabContent3Scale = 0.5
          }
        })
      }
    } as TabContentAnimatedTransition;
    return firstCustomTransition;

  } else {
    // 透明度动画
    let secondCustomTransition = {
      timeout: this.secondTimeout,
      transition: (proxy: TabContentTransitionProxy) => {
        if ((proxy.from === 1 && proxy.to === 2) || (proxy.from === 2 && proxy.to === 1)) {
          if (proxy.from === 1 && proxy.to === 2) {
            this.tabContent1Opacity = 1.0
            this.tabContent2Opacity = 0.5
          } else {
            this.tabContent1Opacity = 0.5
            this.tabContent2Opacity = 1.0
            this.tabContent1Scale = 1.0
          }
          animateTo({
            duration: this.second2thirdDuration,
            onFinish: () => {
              proxy.finishTransition()
            }
          }, () => {
            if (proxy.from === 1 && proxy.to === 2) {
              this.tabContent1Opacity = 0.5
              this.tabContent2Opacity = 1.0
              this.tabContent2Scale = 1.0
            } else {
              this.tabContent1Opacity = 1.0
              this.tabContent2Opacity = 0.5
            }
          })
        } else if ((proxy.from === 0 && proxy.to === 2) || (proxy.from === 2 && proxy.to === 0) || (proxy.from === 0 && proxy.to === 3) || (proxy.from === 3 && proxy.to === 0) ) {
          if (proxy.from === 0 && proxy.to === 2) {
            this.tabContent0Opacity = 1.0
            this.tabContent2Opacity = 0.5
          } else {
            this.tabContent0Opacity = 0.5
            this.tabContent2Opacity = 1.0
          }
          if (proxy.from === 0 && proxy.to === 3) {
            this.tabContent0Opacity = 1.0
            this.tabContent3Opacity = 0.5
          } else {
            this.tabContent0Opacity = 0.5
            this.tabContent3Opacity = 1.0
          }
          animateTo({
            duration: this.first2thirdDuration,
            onFinish: () => {
              proxy.finishTransition()
            }
          }, () => {
            if (proxy.from === 0 && proxy.to === 2) {
              this.tabContent0Opacity = 0.5
              this.tabContent2Opacity = 1.0
            } else {
              this.tabContent0Opacity = 1.0
              this.tabContent2Opacity = 0.5
            }
            if (proxy.from === 0 && proxy.to === 3) {
              this.tabContent0Opacity = 0.5
              this.tabContent3Opacity = 1.0
            } else {
              this.tabContent0Opacity = 1.0
              this.tabContent3Opacity = 0.5
            }
          })
        }
      }
    } as TabContentAnimatedTransition;
    return secondCustomTransition;
  }
}

HarmonyOS码上奇行
7.5k 声望2.9k 粉丝