🎯 案例集合Tabs:自定义tabs凹陷球体 滑动动画

🌍 案例集合Tabs

🚪 最近开启了学员班级点我欢迎加入(学习相关知识可获得定制礼盒)

🏷️ 效果图

📖 参考

🧩 拆解

import { AnimatorResult } from '@ohos.animator';
import { display, window } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';

/**
 * 安全区高度
 */
interface AvoidArea {
  topRectHeight: number
  bottomRectHeight: number
}

/**
 * 安全类型边距枚举
 * statusBarType 状态栏
 * navBarType 导航栏
 */
const statusBarType = window.AvoidAreaType.TYPE_SYSTEM
const navBarType = window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR

/**
 * tabs数据
 */
const mockData: string[] = ['购物', '体育', '服装', '军事']

@Component
export struct TabsConcaveBallCase {
  /**
   * UI上下文
   */
  private context = this.getUIContext()?.getHostContext() as common.UIAbilityContext
  /**
   * 窗口对象
   */
  private windowClass = this.context.windowStage.getMainWindowSync()
  /**
   * 当前选中的Tabs下标(还未切换动画前获取)
   */
  @State animationStartIdx: number = 0
  /**
   * 安全区高度对象(状态栏、导航栏)
   */
  @State avoidArea: AvoidArea = { topRectHeight: 0, bottomRectHeight: 0 }
  /**
   * tabs构造器
   */
  private tabsController: TabsController = new TabsController()
  /**
   * 背景色 文字 | 占位
   */
  private tabsBgColor: string = '#ff818080'
  /**
   * 动画持续时间 文字 | 凹陷 过渡
   */
  private animationDuration: number = 300
  /**
   * 凸出球x轴位置
   */
  @State ballCenterX: number = 0
  /**
   * 配置CanvasRenderingContext2D对象的参数,包括是否开启抗锯齿。
   */
  private canvasContext: CanvasRenderingContext2D = new CanvasRenderingContext2D(new RenderingContextSettings(true))
  /**
   * 画布动画
   */
  private canvasAnimator: AnimatorResult | undefined = undefined
  /**
   * 当前凹槽位置
   */
  @State canvasConcaveAreaX: number = 0

  aboutToAppear(): void {
    this.windowClass.setWindowLayoutFullScreen(true)
    this.windowClass.on('avoidAreaChange', this.onAvoidAreaChange)
    this.setAvoidArea()
  }

  aboutToDisappear(): void {
    this.windowClass.setWindowLayoutFullScreen(false)
    this.windowClass.off('avoidAreaChange', this.onAvoidAreaChange)
  }

  /**
   * 设置状态栏和导航栏避让区域 && 监听不同设备避让区域的变化
   */
  setAvoidArea() {
    const statusBarArea = this.windowClass.getWindowAvoidArea(statusBarType)
    this.avoidArea.topRectHeight = statusBarArea.topRect.height
    const navBarArea = this.windowClass.getWindowAvoidArea(navBarType)
    this.avoidArea.bottomRectHeight = navBarArea.bottomRect.height
  }

  onAvoidAreaChange = (data: window.AvoidAreaOptions) => {
    if (data.type === statusBarType) {
      this.avoidArea.topRectHeight = data.area.topRect.height
    } else if (data.type === navBarType) {
      this.avoidArea.bottomRectHeight = data.area.bottomRect.height
    }
  }

  /**
   * tab
   * @param label
   * @param idx
   */
  @Builder
  tabBuilder(label: string, idx: number) {
    Column({ space: 5 }) {
      Text(label)
        .fontSize(16)
        .fontColor(this.animationStartIdx === idx ? Color.White : Color.Black)
        .fontWeight(this.animationStartIdx === idx ? FontWeight.Medium : FontWeight.Normal)
        .offset({
          y: this.animationStartIdx === idx ? -15 : 0
        })
        .animation({ duration: this.animationDuration, curve: Curve.Smooth })

      Image($r('app.media.startIcon'))
        .syncLoad(true)
        .draggable(false)
        .width(20)
        .height(20)
    }
    .height(60)
    .onClick(() => {
      this.tabsController.changeIndex(idx)
      this.createAnimation()
    })
    .justifyContent(FlexAlign.Center)
    .layoutWeight(1)
  }

  /**
   * 应用于占位
   */
  @Builder
  slotBuilder() {
    Text()
      .width('100%')
      .height(this.getUIContext().px2vp(this.avoidArea.bottomRectHeight))
      .backgroundColor(this.tabsBgColor)
  }

  /**
   * 获取菜单选项的中间点
   */
  private getMenuCenterX(): number {
    const itemWidth = this.getUIContext().px2vp(display.getDefaultDisplaySync().width) / mockData.length
    // 获取当前下标项的中心位置
    return (itemWidth * this.animationStartIdx) + (itemWidth / 2)
  }

  /**
   * 获取兼容性宽度
   * @param { number } width
   * @param { number } height
   * @param { number } menuLength
   * @returns { number } 适配当菜单数量,取菜单的宽度和tabs高度,把小数值返回作为后续使用
   */
  private getMinWidth(width: number, height: number, menuLength: number = 0): number {
    return Math.min(width / menuLength, height)
  }

  /**
   *
   * 创建移动动画
   * 用于移动圆球和重绘canvas中凹陷部分
   * @param duration
   */
  private createAnimation(duration?: number) {
    this.canvasAnimator = this.getUIContext().createAnimator({
      duration: duration ?? this.animationDuration,
      easing: "ease",
      delay: 0, // 动画等待时长
      fill: "forwards",
      direction: "normal",
      iterations: 1, // 动画执行次数
      begin: this.canvasConcaveAreaX,
      end: this.getMenuCenterX()
    })

    this.canvasAnimator.onFrame = (value: number) => {
      this.canvasConcaveAreaX = value
      // 减去凸出球体的一半 (居中)
      this.ballCenterX = value - 25
      this.createCanvas()
    }

    this.canvasAnimator.play()
  }

  /**
   * 创建canvas 背景 和 凹槽
   */
  private createCanvas() {
    this.canvasContext.reset()
    this.canvasCreateRectangle()
    this.canvasClipGroove()
  }

  /**
   * 绘制 Canvas 大小和填充颜色
   */
  private canvasCreateRectangle() {
    // CanvasRenderingContext2D
    const ctx = this.canvasContext
    // canvas 宽度
    const cW = ctx.width
    // canvas 高度
    const cH = ctx.height

    ctx.clearRect(0, 0, cW, cH)
    ctx.beginPath()
    ctx.moveTo(0, 0)
    ctx.lineTo(cW, 0)
    ctx.lineTo(cW, cH)
    ctx.lineTo(0, cH)
    ctx.closePath()
    ctx.fillStyle = this.tabsBgColor // 设置填充颜色
    ctx.fill()
    ctx.closePath()
  }

  /**
   * 给 Canvas 切割悬浮槽
   * TODO: 知识点:通过 clip 来实现切割后保留的圆弧实现平滑过渡
   */
  private canvasClipGroove() {
    // CanvasRenderingContext2D
    const ctx = this.canvasContext
    // canvas 宽度
    const cW = ctx.width
    // canvas 高度
    const cH = ctx.height
    // 半径
    const radius = this.getMinWidth(cW, cH, mockData.length) / 2
    // 中间圆的中心点
    const circleCenter = this.canvasConcaveAreaX || cW / 2
    // 计算左右俩倒角的x轴距离圆心的距离
    const aroundCenter = Math.sqrt(Math.pow(radius * 2, 2) - Math.pow(radius, 2))
    /**
     * canvas 绘制的度数  1°
     * 这里是 1° 如果需要多少度 直接 * 具体数字即可
     */
    const chamferDegrees1 = Math.PI / 180
    // 330°
    const chamferDegrees330 = chamferDegrees1 * 330
    // 270°
    const chamferDegrees270 = chamferDegrees1 * 270
    // 210°
    const chamferDegrees210 = chamferDegrees1 * 210
    // 150°
    const chamferDegrees150 = chamferDegrees1 * 150
    // 30°
    const chamferDegrees30 = chamferDegrees1 * 30

    ctx.beginPath()
    // 左边圆弧线
    ctx.arc(circleCenter - aroundCenter, radius, radius, chamferDegrees270, chamferDegrees330)
    // 中间圆弧线
    ctx.arc(circleCenter, 0, radius, chamferDegrees30, chamferDegrees150)
    // 右边圆弧线
    ctx.arc(circleCenter + aroundCenter, radius, radius, chamferDegrees210, chamferDegrees270)
    ctx.closePath()
    // 应用剪裁路径
    ctx.clip()
    // 清除剪裁区域内的部分
    ctx.clearRect(0, 0, cW, cH)
  }

  build() {
    Stack({ alignContent: Alignment.BottomStart }) {
      Tabs({ controller: this.tabsController }) {
        ForEach(mockData, (item: string) => {
          TabContent() {
            Column() {
              Text(item)
                .fontColor(Color.White)
                .fontSize(20)
                .fontWeight(FontWeight.Bold)
            }
            .width('100%')
            .height('100%')
            .justifyContent(FlexAlign.Center)
            .backgroundColor(Color.Brown)
          }
        })
      }
      .barHeight(0)
      // 关键:切换动画开始时触发该回调:解决切换tabs延迟的问题
      .onAnimationStart((idx: number, targetIndex: number) => {
        if (idx === targetIndex) {
          return
        }
        this.animationStartIdx = targetIndex
        this.createAnimation()
      })

      Column() {
        Stack() {
          // 背景 -  凹槽
          Canvas(this.canvasContext)
            .width('100%')
            .height('100%')
            // duration 第一次创建的时候不给动画
            .onReady(() => this.createAnimation(0))

          // 凸出球体
          Text()
            .width(50)
            .aspectRatio(1)
            .backgroundColor(this.tabsBgColor)
            .borderRadius(25)
            .position({
              y: -25,
              x: this.ballCenterX
            })

          // 自定义tabs
          Row() {
            ForEach(mockData, (item: string, idx: number) => {
              this.tabBuilder(item, idx)
            })
          }
          .width('100%')
        }
        .height(60)
        .width('100%')

        // 安全边距占位-未在最大容器设置背景规避凹陷区域被覆盖
        this.slotBuilder()
      }
    }
    .width('100%')
    .height('100%')
    .padding({ top: this.avoidArea.topRectHeight + 'px' })
  }
}

🌸🌼🌺

Logo

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

更多推荐