Skip to content

Worklet 动画

微信小程序是双线程的,分为 UI 线程和 JS 线程,两者独立运行,不会相互阻塞

既然相互独立,那沟通就会有延迟,以以下代码为例:

html
<view
  class="wrapper"
  bindtap="onChangeOpacity"
  style="opacity: {{ opacity }}"
></view>
js
Component({
  data: {
    opacity: 1,
  },
  methods: {
    onChangeOpacity() {
      this.setData({
        opacity: this.data.opacity ? 0 : 1,
      })
    },
  },
})
css
.wrapper {
  width: 200px;
  height: 200px;
  background: red;
  transition: opacity 1.5s;
}

通过点击背景让其颜色由浅至深、由深至浅。执行顺序为:

[UI 线程] tap 事件,通知

-> [JS 线程] 执行 onChangeOpacity 回调;检测到opacity变化,通知

-> [UI]线程 绘制(应用动画)

上述场景这种方式并没什么不妥,不过一旦涉及到手势交互、频发触发等场景;这种双向沟通机制就会存在延迟、不确定性

因此,就需要一种可以运行在 UI 线程的逻辑,这样就不需要 UI 和 JS 两个线程之间反复横跳了

worklet 就应运而生,它不仅可以在 UI 线程运行,也可以在 JS 线程运行

核心概念

  • const { shared } = wx.worklet

    shared定义 UI 和 JS 线程的共享变量,可以类比vue3ref,访问也是通过.value

    (定义的变量假如不是shared类型,worklet函数如果有访问它,会在一开始序列化后生成在 UI 线程的拷贝,后续 JS 线程对它的更新都不会同步至 UI 线程)

  • applyAnimatedStyle(selector, updater[, userConfig, callback])

    通过页面/组件实例访问,该函数通过selector选中对应元素,updater(worklet函数)返回一个styleObject,这个styleObject中的valuesharedValue绑定。当sharedValue变化时,触发updater执行,元素就有动画了

html
<pan-gesture-handler worklet:ongesture="onPan">
  <view class="circle"></view>
</pan-gesture-handler>
js
const { shared } = wx.worklet
const offset = shared(0)
const GestureStateEnum {
  // 手势未识别
  POSSIBLE: 0,
  // 手势已识别
  BEGIN: 1,
  // 连续手势活跃状态
  ACTIVE: 2,
  // 手势终止
  END: 3,
  // 手势取消
  CANCELLED: 4,
}
Component({
  lifetimes: {
    ready() {
      this.applyAnimatedStyle('.circle', () => {
        'worklet'
        return {
          transform: `translateX(${offset.value}px)`,
        }
      })
    },
  },
  methods: {
    onPan(evt) {
      'worklet'
      if (evt.state === GestureStateEnum.ACTIVE) {
        offset.value += evt.deltaX
      }
    },
  },
})
  • 动画函数:const { timing, Easing, spring, decay } = wx.worklet

    动画函数都是第一个参数接收目标值toValue,后续参数(可选的)接收配置。返回值赋值给sharedValue,由此便完成了动画的闭环

    timing:基于时间的动画,动画曲线由Easing值决定

    spring:基于物理的动画

    decay:基于滚动衰减的动画

案例

案例一

实现:

  • 上部分:image + 下部分:scroll-view 布局
  • 下拉时,图片和 scroll-view 同步下移(可以有个最大下移距离),松手回弹
  • 上滑时,scroll-view 容器先上移至与系统状态栏对齐,再进行内部的滚动

步骤 1:实现下拉回弹

初步实现
html
<image
  class="banner absolute w-full"
  src="https://images.pexels.com/photos/2912996/pexels-photo-2912996.jpeg?auto=compress&cs=tinysrgb&w=800"
  fade-in
></image>
<view class="flex-1">
  <!-- 该手势组件先通过native-view="scroll-view"代理该标签,再通过onShouldAcceptGesture和onShouldAcceptGesture的逻辑来控制scroll-view层上的手势是否有效,控制内部滚动 -->
  <vertical-drag-gesture-handler
    native-view="scroll-view"
    worklet:ongesture="onDragScrollView"
    should-response-on-move="onShouldResponseOnMove"
    should-accept-gesture="onShouldAcceptGesture"
  >
    <scroll-view
      class="h-full"
      scroll-y
    >
      ...
    </scroll-view>
  </vertical-drag-gesture-handler>
</view>
js
const { shared, timing } = wx.worklet
const imageHeight = shared(0)
const GestureStateEnum {
  // 手势未识别
  POSSIBLE: 0,
  // 手势已识别
  BEGIN: 1,
  // 连续手势活跃状态
  ACTIVE: 2,
  // 手势终止
  END: 3,
  // 手势取消
  CANCELLED: 4,
}
Component({
  data: {
    initImageHeight: 474,
  },
  lifetimes: {
    ready() {
      imageHeight.value = this.data.initImageHeight
      this.applyAnimatedStyle('.banner', () => {
        'worklet'
        return {
          height: `${imageHeight.value}rpx`,
        }
      })
    },
  },
  methods: {
    onDragScrollView(evt) {
      'worklet'
      if (evt.state === GestureStateEnum.ACTIVE) {
        if (evt.deltaY > 0) {
          // 下拉,图片高度增加,scroll-view的bounces下拉效果
          imageHeight.value += evt.deltaY
        } else {
          // 上滑
        }
      } else if ([GestureStateEnum.END, GestureStateEnum.CANCELLED].includes(evt.state)) {
        // 手势结束,重置状态
        imageHeight.value = timing(this.data.initImageHeight)
      }
    },
    onShouldResponseOnMove() {
      'worklet'
      return true
    },
    onShouldAcceptGesture() {
      'worklet'
      return true
    },
  },
})

skyline 下,scroll-viewbounces效果在AndroidiOS均生效,即:向下拉动,顶部会跟随展示背景色,松手回弹。那一开始将image设置成absolute,然后在worklet:ongesture中改变imageheight,即可实现下拉,上下部分同步下移。但是:回弹时,scroll-view的回弹动画和时间不得而知,所以两者的回弹效果始终是对不齐的

因此,需要关闭bounces效果,下半部分也通过动画以实现上下运动均同步,以下是改进后的代码:

改进实现
html
<image
  class="banner absolute w-full"
  src="https://images.pexels.com/photos/2912996/pexels-photo-2912996.jpeg?auto=compress&cs=tinysrgb&w=800"
  fade-in
></image>
<view class="sv flex-1"> 
  <!-- 该手势组件先通过native-view="scroll-view"代理该标签,再通过onShouldAcceptGesture和onShouldAcceptGesture的逻辑来控制scroll-view层上的手势是否有效,控制内部滚动 -->
  <vertical-drag-gesture-handler
    native-view="scroll-view"
    worklet:ongesture="onDragScrollView"
    should-response-on-move="onShouldResponseOnMove"
    should-accept-gesture="onShouldAcceptGesture"
  >
    <scroll-view
      class="h-full"
      scroll-y
      bounces="{{ false }}"> 
      ...
    </scroll-view>
  </vertical-drag-gesture-handler>
</view>
js
const { shared, timing } = wx.worklet
const imageHeight = shared(0)
const scrollViewTranslateY = shared(0) 
const GestureStateEnum {
  // 手势未识别
  POSSIBLE: 0,
  // 手势已识别
  BEGIN: 1,
  // 连续手势活跃状态
  ACTIVE: 2,
  // 手势终止
  END: 3,
  // 手势取消
  CANCELLED: 4,
}
Component({
  data: {
    initImageHeight: 474,
  },
  lifetimes: {
    ready() {
      scrollViewTranslateY.value = imageHeight.value = this.data.initImageHeight 
      this.applyAnimatedStyle('.banner', () => {
        'worklet'
        return {
          height: `${imageHeight.value}rpx`,
        }
      })
      this.applyAnimatedStyle('.sv', () => { 
        'worklet'
        return { 
          transform: `translateY(${scrollViewTranslateY.value}rpx)`, 
        } 
      }) 
    },
  },
  methods: {
    onDragScrollView(evt) {
      'worklet'
      if (evt.state === GestureStateEnum.ACTIVE) {
        if (evt.deltaY > 0) {
          // 下拉,图片高度增加,scroll-view向下平移
          imageHeight.value += evt.deltaY
          scrollViewTranslateY.value += evt.deltaY 
        } else {
          // 上滑
        }
      } else if ([GestureStateEnum.END, GestureStateEnum.CANCELLED].includes(evt.state)) {
        // 手势结束,重置状态
        scrollViewTranslateY.value = imageHeight.value = timing(this.data.initImageHeight) 
      }
    },
    onShouldResponseOnMove() {
      'worklet'
      return true
    },
    onShouldAcceptGesture() {
      'worklet'
      return true
    },
  },
})

步骤 2:上滑时,容器先上移,再内部滚动

如果手势向上滑动时,scroll-view是向上平移还是内部滚动呢?该怎么写逻辑呢?

前面部分已经知道,vertical-drag-gesture-handler手势组件的should-accept-gestureshould-response-on-move回调可以控制当前手势有没有效,如果返回true就是有效,返回false就是无效

向不向上平移还是内部滚动其实都依赖一个关键点,就是:scroll-viewtranlateY当前是 在起始位置 还是 到达了顶部位置

已知:translateY起始位置是initImageHeight,我们定义一个顶部位置为topDestination

因此要控制scroll-view能不能滚动,可以在should-response-on-move中返回:evt.deltaY < 0 && scrollViewTranslateY.value === topDestination

此时问题就来了:如果should-response-on-move这么判定,初始时,scrollViewTranslateY.valueinitImageHeight,向上滑动时,判定为false,永远也触发不了worklet:ongesture的回调,那谁来改变scrollViewTranslateY.value值到topDestination呢?

答案:手势协商,通过两个vertical-drag-gesture-handler组件,外围控制纵向拖动,内侧控制内部滚动。再用simultaneous-handlers声明两个手势可同时触发

html
<image
  class="banner absolute w-full"
  src="https://images.pexels.com/photos/2912996/pexels-photo-2912996.jpeg?auto=compress&cs=tinysrgb&w=800"
  fade-in
></image>
<view class="sv flex-1">
  <!-- 该手势组件控制向上拖动 -->
  <vertical-drag-gesture-handler
    worklet:ongesture="onDragScrollViewOuter"
    tag="outer"
    simultaneous-handlers="{{['inner']}}"
  >
    <!-- 该手势组件先通过native-view="scroll-view"代理该标签,再通过onShouldAcceptGesture和onShouldAcceptGesture的逻辑来控制scroll-view层上的手势是否有效,控制内部滚动 -->
    <vertical-drag-gesture-handler
      tag="inner"
      native-view="scroll-view"
      worklet:ongesture="onDragScrollViewInner"
      should-accept-gesture="onShouldAcceptGestureInner"
      simultaneous-handlers="{{['outer']}}"
    >
      <scroll-view
        class="h-full"
        scroll-y
        bounces="{{ false }}"
      >

        ...
      </scroll-view>
    </vertical-drag-gesture-handler>
  </vertical-drag-gesture-handler>
</view>
js
const { shared, timing } = wx.worklet
const imageHeight = shared(0)
const scrollViewTranslateY = shared(0)
const GestureStateEnum {
  // 手势未识别
  POSSIBLE: 0,
  // 手势已识别
  BEGIN: 1,
  // 连续手势活跃状态
  ACTIVE: 2,
  // 手势终止
  END: 3,
  // 手势取消
  CANCELLED: 4,
}
Component({
  data: {
    initImageHeight: 474,
  },
  lifetimes: {
    ready() {
      scrollViewTranslateY.value = imageHeight.value = this.data.initImageHeight 
      this.applyAnimatedStyle('.banner', () => {
        'worklet'
        return {
          height: `${imageHeight.value}rpx`,
        }
      })
      this.applyAnimatedStyle('.sv', () => { 
        'worklet'
        return { 
          transform: `translateY(${scrollViewTranslateY.value}rpx)`, 
        } 
      }) 
    },
  },
  methods: {
    onDragScrollView(evt) {
      'worklet'
      if (evt.state === GestureStateEnum.ACTIVE) {
        if (evt.deltaY > 0) {
          // 下拉,图片高度增加,scroll-view向下平移
          imageHeight.value += evt.deltaY
          scrollViewTranslateY.value += evt.deltaY 
        } else {
          // 上滑
        }
      } else if ([GestureStateEnum.END, GestureStateEnum.CANCELLED].includes(evt.state)) {
        // 手势结束,重置状态
        scrollViewTranslateY.value = imageHeight.value = timing(this.data.initImageHeight) 
      }
    },
    onShouldAcceptGestureInner() {
      'worklet'
      return true
    },
  },
})