当前位置: 首页 > article >正文

【HarmonyOS Next】自定义Tabs

背景

项目中Tabs的使用可以说是特别的频繁,但是官方提供的Tabs使用起来,存在tab选项卡切换动画滞后的问题。
请添加图片描述
原始动画无法满足产品的UI需求,因此,这篇文章将实现下面页面滑动,tab选项卡实时滑动的动画效果。
请添加图片描述

实现逻辑

需求讲解

  • 需要实现固定宽度下,放下6个选项卡。
  • 在没有选择时宽度均匀分配,选中时显示图标并且增加宽度。
  • 实现下方内容区域滑动时,上面选项卡实时跳动。
  • 实现动画效果,使整体操作更加流畅。

实现思路

1. 选项卡

  • 选项卡使用Row布局组件+layoutWeight属性,来实现平均布局。通过选项卡的选择索引来实现是否选中判断。
  • 选中时,layoutWeight值为1.5;没有选中时,layoutWeight值为1.
  • 使用animation属性,只要layoutWeight值变化时,可以触发动画。
  • 在外包裹的布局容器中,添加onAreaChange事件,用来计算整体Tab组件的宽度。
          Row() {
            Text(name)
              .fontSize(16)
              .fontWeight(this.SelectedTabIndex == index ? FontWeight.Bold : FontWeight.Normal)
              .textAlign(TextAlign.Center)
              .animation({ duration: 300 })
            Image($r('app.media.send'))
              .width(14)
              .height(14)
              .margin({ left: 2 })
              .visibility(this.SelectedTabIndex == index ? Visibility.Visible : Visibility.None)
              .animation({ duration: 300 })
          }
          .justifyContent(FlexAlign.Center)
          .layoutWeight(this.SelectedTabIndex == index ? 1.5 : 1)
          .animation({ duration: 300 })

2. 定位器

  • 使用Rect定义背景的形状和颜色+Stack布局+position属性,实现定位器的移动。
  • position属性中通过Left值的变化来实现Rect的移动。但是在swiper的滑动中会出现滑动一点然后松开的情况,因此,需要两个值同时在实现中间的移动过程。
      Stack() {
        Rect()
          .height(30)
          .stroke(Color.Black)
          .radius(10)
          .width(this.FirstWidth)
          .fill("#bff9f2")
          .position({
            left: this.IndicatorLeftOffset + this.IndicatorOffset,
            bottom: 0
          })
          .animation({ duration: 300, curve: Curve.LinearOutSlowIn })
      }
      .width("100%")
      .alignRules({
        center: { anchor: "Tabs", align: VerticalAlign.Center }
      })

3.主要内容区

  • 使用Swiper组件加载对应的组件,这里需要注意的是,Demo没有考虑到内容比较多的优化方案,可以设置懒加载方案来实现性能的提升。
  • onAnimationStart事件,实现监测控件是向左移动还是向右移动,并且修改IndicatorLeftOffset偏移值。
  • onAnimationEnd事件,将中间移动过程值IndicatorOffset恢复成0。
  • onGestureSwipe事件,监测组件的实时滑动,这个事件在onAnimationStart和onAnimationEnd事件之前执行,执行完后才会执行onAnimationStart事件。因此,这个方法需要实时修改定位器的偏移数值。
  • 偏移数值是通过swiper的移动数值和整体宽度的比例方式进行计算,松手后的偏移方向,由onAnimationStart和onAnimationEnd事件来确定最终的距离

      Swiper(this.SwiperController) {
        ForEach(this.TabNames, (name: string, index: number) => {
          Column() {
            Text(`${name} - ${index}`)
              .fontSize(24)
              .fontWeight(FontWeight.Bold)
          }
          .alignItems(HorizontalAlign.Center)
          .justifyContent(FlexAlign.Center)
          .height("100%")
          .width("100%")
        })
      }
      .onAnimationStart((index: number, targetIndex: number, extraInfo: SwiperAnimationEvent) => {
        if (targetIndex > index) {
          this.IndicatorLeftOffset += this.OtherWidth;
        } else if (targetIndex < index) {
          this.IndicatorLeftOffset -= this.OtherWidth;
        }
        this.IndicatorOffset = 0
        this.SelectedTabIndex = targetIndex

      })
      .onAnimationEnd((index: number, extraInfo: SwiperAnimationEvent) => {
        this.IndicatorOffset = 0
      })
      .onGestureSwipe((index: number, extraInfo: SwiperAnimationEvent) => {
        let move: number = this.GetOffset(extraInfo.currentOffset);
        //这里需要限制边缘情况
        if ((this.SelectedTabIndex == 0 && extraInfo.currentOffset > 0) ||
          (this.SelectedTabIndex == this.TabNames.length - 1 && extraInfo.currentOffset < 0)) {
          return;
        }
        this.IndicatorOffset = extraInfo.currentOffset < 0 ? move : -move;
      })
      .onAreaChange((oldValue: Area, newValue: Area) => {
        let width = newValue.width.valueOf() as number;
        this.SwiperWidth = width;
      })
      .curve(Curve.LinearOutSlowIn)
      .loop(false)
      .indicator(false)
      .width("100%")
      .id("MainContext")
      .alignRules({
        top: { anchor: "Tabs", align: VerticalAlign.Bottom },
        bottom: { anchor: "__container__", align: VerticalAlign.Bottom }
      })

代码文件

  • 里面涉及到资源的小图标,可以自己区定义的,文章就不提供了。
@Entry
@ComponentV2
struct Index {
  /**
   * 标头名称集合
   */
  @Local TabNames: string[] = ["飞机", "铁路", "自驾", "地铁", "公交", "骑行"]
  /**
   * Tab选择索引
   */
  @Local SelectedTabIndex: number = 0
  /**
   * 标点移动距离
   */
  @Local IndicatorLeftOffset: number = 0
  /**
   * 标点在swiper的带动下移动的距离
   */
  @Local IndicatorOffset: number = 0
  /**
   * 第一个宽度
   */
  @Local FirstWidth: number = -1
  /**
   * 其他的宽度
   */
  @Local OtherWidth: number = -1
  /**
   * Swiper控制器
   */
  @Local SwiperController: SwiperController = new SwiperController()
  /**
   * Swiper容器宽度
   */
  @Local SwiperWidth: number = 0

  build() {
    RelativeContainer() {
      Stack() {
        Rect()
          .height(30)
          .stroke(Color.Black)
          .radius(10)
          .width(this.FirstWidth)
          .fill("#bff9f2")
          .position({
            left: this.IndicatorLeftOffset + this.IndicatorOffset,
            bottom: 0
          })
          .animation({ duration: 300, curve: Curve.LinearOutSlowIn })
      }
      .width("100%")
      .alignRules({
        center: { anchor: "Tabs", align: VerticalAlign.Center }
      })

      Row() {
        ForEach(this.TabNames, (name: string, index: number) => {
          Row() {
            Text(name)
              .fontSize(16)
              .fontWeight(this.SelectedTabIndex == index ? FontWeight.Bold : FontWeight.Normal)
              .textAlign(TextAlign.Center)
              .animation({ duration: 300 })
            Image($r('app.media.send'))
              .width(14)
              .height(14)
              .margin({ left: 2 })
              .visibility(this.SelectedTabIndex == index ? Visibility.Visible : Visibility.None)
              .animation({ duration: 300 })
          }
          .justifyContent(FlexAlign.Center)
          .layoutWeight(this.SelectedTabIndex == index ? 1.5 : 1)
          .animation({ duration: 300 })
          .onClick(() => {
            this.SelectedTabIndex = index;
            this.SwiperController.changeIndex(index, false);
            animateTo({ duration: 500, curve: Curve.LinearOutSlowIn }, () => {
              this.IndicatorLeftOffset = this.OtherWidth * index;
            })
          })

        })
      }
      .width("100%")
      .height(30)
      .id("Tabs")
      .onAreaChange((oldValue: Area, newValue: Area) => {
        let tabWidth = newValue.width.valueOf() as number;
        this.FirstWidth = 1.5 * tabWidth / (this.TabNames.length + 0.5);
        this.OtherWidth = tabWidth / (this.TabNames.length + 0.5);
      })

      Swiper(this.SwiperController) {
        ForEach(this.TabNames, (name: string, index: number) => {
          Column() {
            Text(`${name} - ${index}`)
              .fontSize(24)
              .fontWeight(FontWeight.Bold)
          }
          .alignItems(HorizontalAlign.Center)
          .justifyContent(FlexAlign.Center)
          .height("100%")
          .width("100%")
        })
      }
      .onAnimationStart((index: number, targetIndex: number, extraInfo: SwiperAnimationEvent) => {
        if (targetIndex > index) {
          this.IndicatorLeftOffset += this.OtherWidth;
        } else if (targetIndex < index) {
          this.IndicatorLeftOffset -= this.OtherWidth;
        }
        this.IndicatorOffset = 0
        this.SelectedTabIndex = targetIndex

      })
      .onAnimationEnd((index: number, extraInfo: SwiperAnimationEvent) => {
        this.IndicatorOffset = 0
      })
      .onGestureSwipe((index: number, extraInfo: SwiperAnimationEvent) => {
        let move: number = this.GetOffset(extraInfo.currentOffset);
        //这里需要限制边缘情况
        if ((this.SelectedTabIndex == 0 && extraInfo.currentOffset > 0) ||
          (this.SelectedTabIndex == this.TabNames.length - 1 && extraInfo.currentOffset < 0)) {
          return;
        }
        this.IndicatorOffset = extraInfo.currentOffset < 0 ? move : -move;
      })
      .onAreaChange((oldValue: Area, newValue: Area) => {
        let width = newValue.width.valueOf() as number;
        this.SwiperWidth = width;
      })
      .curve(Curve.LinearOutSlowIn)
      .loop(false)
      .indicator(false)
      .width("100%")
      .id("MainContext")
      .alignRules({
        top: { anchor: "Tabs", align: VerticalAlign.Bottom },
        bottom: { anchor: "__container__", align: VerticalAlign.Bottom }
      })

    }
    .height('100%')
    .width('100%')
    .padding(10)
  }

  /**
   * 需要注意的点,当前方法仅计算偏移值,不带方向
   * @param swiperOffset
   * @returns
   */
  GetOffset(swiperOffset: number): number {
    let swiperMoveRatio: number = Math.abs(swiperOffset / this.SwiperWidth);
    let tabMoveValue: number = swiperMoveRatio >= 1 ? this.OtherWidth : this.OtherWidth * swiperMoveRatio;
    return tabMoveValue;
  }
}

总结

这里实现了新的Tab选项卡的定义。但是没有进行高度封装,想法是方便读者理解组件的使用逻辑,而不是直接提供给读者进行调用。希望这篇文章可以帮助到你~~


http://www.kler.cn/a/572442.html

相关文章:

  • [杂学笔记]面向对象特性、右值引用与移动语义、push_back与emplace_back的区别、读写锁与智能指针对锁的管理、访问网站的全过程
  • 大数据学习(52)-MySQL数据库基本操作
  • QT实现单个控制点在曲线上的贝塞尔曲线
  • c#窗体按键点击事件
  • GenBI 可视化选谁:Python Matplotlib?HTML ?Tableau?
  • 计算机毕业设计SpringBoot+Vue.js电商平台(源码+文档+PPT+讲解)
  • UE身体发光设置覆层材质
  • 高压电路试题(二)
  • sqli-labs靶场通关攻略
  • 网络配置的基本信息
  • 如何提高测试用例覆盖率?
  • 深入解析 ASP.NET Core 的内存管理与垃圾回收优化
  • 大语言模型的逻辑:从“鹦鹉学舌”到“举一反三”
  • LeetCode 88 - 合并两个有序数组
  • 文件上传漏洞:upload-labs靶场1-10
  • 【RabbitMQ】RabbitMQ的核心概念与七大工作模式
  • NativeScript 8.9.0 发布,跨平台原生应用框架
  • 【html期末作业网页设计】
  • [杂学笔记] 封装、继承、多态,堆和栈的区别,堆和栈的区别 ,托管与非托管 ,c++的垃圾回收机制 , 实现一个单例模式 注意事项
  • 金属玻璃拼接设计,iPhone 17重回经典,致敬苹果5