worklet 动画

微信小程序采用双线程架构,渲染线程(UI 线程)和逻辑线程(JS 线程)分离。JS 线程不会影响 UI 线程的动画表现,如滚动效果。但引入的问题是,UI 线程的事件发生后,需跨线程传递到 JS 线程,进而触发开发者回调,当做交互动画(如拖动元素)时,这种异步性会带来较大的延迟和不稳定。

worklet 动画正是为解决这类问题而诞生的,使得微信小程序可以做到类原生动画般的体验。

立即体验

使用 worklet 动画能力时确保以下两项:

  • 确保开发者工具右上角 > 详情 > 本地设置里的 将 JS 编译成 ES5 选项被勾选上 (代码包体积会少量增加)
  • worklet 动画相关接口仅在 Skyline 渲染模式下才能使用

首先,我们需要了解一些相关概念。

概念一:worklet 函数

一种声明在开发者代码中,可运行在 JS 线程或 UI 线程的函数,函数体顶部有 'worklet' 指令声明。

worklet 函数定义

function someWorklet(greeting) {
  'worklet';
  console.log(greeting);
}

// 运行在 JS 线程
someWorklet('hello') // print: hello

// 运行在 UI 线程
wx.worklet.runOnUI(someWorklet)('hello') // print: [ui] hello

worklet 函数间相互调用

const name = 'skyline'

function anotherWorklet() {
  'worklet';
  return 'hello ' + name;
}

// worklet 函数间可互相调用
function someWorklet() {
  'worklet';
  const greeting = anotherWorklet();
  console.log('another worklet says ', greeting);
}

wx.worklet.runOnUI(someWorklet)() // print: [ui] another worklet says hello skyline

从 UI 线程调回到 JS 线程

function someFunc(greeting) {
  console.log('hello', greeting);
}

function someWorklet() {
  'worklet'
  // 访问非 worklet 函数时,需使用 runOnJS
  // someFunc 运行在 JS 线程
  runOnJS(someFunc)('skyline')
}

wx.worklet.runOnUI(someWorklet)() // print: hello skyline

概念二:共享变量

JS 线程创建,可在两个线程间同步的变量。

const { shared, runOnUI } = wx.worklet

const offset = shared(0)
function someWorklet() {
  'worklet'
  console.log(offset.value) // print: 1
  // 在 UI 线程修改
  offset.value = 2
  console.log(offset.value) // print: 2
}
// 在 JS 线程修改
offset.value = 1

runOnUI(someWorklet)()

shared 函数创建的变量,我们称为 sharedValue 共享变量。用法上可类比 vue3 中的 ref,对它的读写都需要通过 .value 属性,但需注意的是它们并不是一个概念。sharedValue 的用途主要如下。

跨线程共享数据

worklet 函数捕获的外部变量,实际上会被序列化后生成在 UI 线程的拷贝,如下代码中, someWorklet 捕获了 obj 变量,尽管我们修改了 objname 属性,但在 someWorklet 声明的位置,obj 已经被序列化发送到了 UI 线程,因此后续的修改是无法同步的。

const obj = { name: 'skyline'}
function someWorklet() {
  'worklet'
  console.log(obj.name) // 输出的仍旧是 skyline
}
obj.name = 'change name'

wx.worklet.runOnUI(someWorklet)() 

sharedValue 就是用来在线程间同步状态变化的变量。

const { shared, runOnUI } = wx.worklet

const offset = shared(0)
function someWorklet() {
  'worklet'
  console.log(offset.value) // 输出的是新值 1
}
offset.value = 1

runOnUI(someWorklet)() 

驱动动画

worklet 函数和共享变量就是用来解决交互动画问题的。相关接口 applyAnimatedStyle 可通过页面/组件实例访问,接口文档参考

<view id="moved-box"></view>
<view id="btn" bind:tap="tap">点击驱动小球移动</view>
Page({
  onLoad() {
    const offset = wx.worklet.shared(0)
    this.applyAnimatedStyle('#moved-box', () => {
      'worklet';
      return {
        transform: `translateX(${offset.value}px)`
      }
    })
    this._offset = offset
  },
  tap() {
    // 点击时修改 sharedValue 值,驱动小球移动
    this._offset.value = Math.random()
  }
})

当点击按钮 #btn 时,我们用随机数给 offset 进行赋值,小球会随之移动。

applyAnimatedStyle 接口的第二个参数 updater 为一个 worklet 函数,其捕获了共享变量 offset,当 offset 的值变化时,updater 会重新执行,并将返回的新 styleObject 应用到选中节点上。

当然,光看这个例子,跟用 setData 看好像没有什么区别。但当 worklet 动画和手势结合时,就产生了质变。

示例用法

手势处理

<pan-gesture-handler onGestureEvent="handlepan">
  <view class="circle"></view>
</pan-gesture-handler>
Page({
  onLoad() {
    const offset = wx.worklet.shared(0);
    this.applyAnimatedStyle('.circle', () => {
      'worklet';
      return {
        transform: `translateX(${offset.value}px)`
      };
    });
    this._offset = offset;
  },
  handlepan(evt) {
    'worklet';
    if (evt.state === GestureState.ACTIVE) {
      this._offset.value += evt.deltaX;
    }
  }
});

当手指在 circle 节点上移动时,会产生平滑的拖动效果。handlepan 回调触发在 UI 线程,同时我们修改了 offset 的值,会在 UI 线程产生动画,不必再绕回到 JS 线程。

查看更多手势处理的示例代码

自定义动画曲线

<view id="moved-box"></view>
<view id="btn" bind:tap="tap">点击驱动小球移动</view>
const { shared, Easing, timing } = wx.worklet
Page({
  onLoad() {
    const offset = shared(0)
    this.applyAnimatedStyle('#moved-box', () => {
      'worklet';
      return {
        transform: `translateX(${offset.value}px)`
      }
    })
    this._offset = offset
  },
  tap() {
    /**
     * 目标值 300
     * 动画时长 200ms
     * 动画曲线 Easing.ease
     */
    this._offset.value = timing(300, {
      duration: 200,
      easing: Easing.ease
    })
  }
})

内置如 timingspring 等常见动画方式的封装方法,开发者可自定义动画曲线,同时可对不同的动画类型进行组合、重复,形成交织动画。

查看更多不同动画类型用法

查看更多缓动函数的示例代码

相关接口

  • 基础类型 shared、 derived、cancelAnimation

  • 工具函数 runOnUI、runOnJS

  • 动画类型 timing、spring、decay

  • 组合动画 sequence、repeat、delay

  • 缓动函数 Easing

  • 页面实例方法 applyAnimatedStyle、clearAnimatedStyle

注意事项

worklet 函数内部有一些调用上的限制需要留意

  1. 页面/组件实例中定义的 worklet 类型回调函数,内部访问 wx 上的接口,可按如下方式,通过 runOnJS 调回到 JS 线程。
  2. worklet 函数引用的外部变量,对象类型将被 Object.freeze 冻结,使用时需直接访问对象上具体的属性。
<tap-gesture-handler onGestureEvent="handleTap">
  <view class="circle" >showModal</view>
</tap-gesture-handler>
const { runOnJS, timing } = wx.worklet
Page({
  data: {
    msg: 'Skyline'
  },
  onLoad() {
    const toValue = 100
    const showModal = this.showModal.bind(this)
    timing(toValue, { duration: 300 }, () => {
      'worklet'
      runOnJS(showModal)(msg)
    })
  },
  handleTap() {
    'worklet'

    // 非常重要!!!
    // const { msg } = this.data
    // 通过解构 this.data 访问 msg,此时 this.data 将被 Object.freeze 冻结,会导致 setData 无法生效
    // 而通过 this.data.msg 则不会冻结 this.data
    const msg = `hello ${this.data.msg}`

    // 非常重要!!!
    // Page method 必须通过 this.methodName.bind(this) 访问
    const showModal = this.showModal.bind(this)

    // 场景一:返回 JS 线程
    runOnJS(showModal)(msg)

    // 场景二:动画完成回调里返回 JS 线程
    const toValue = 100
    timing(toValue, { duration: 300 }, () => {
      'worklet'
      runOnJS(showModal)(msg)
    })

    // 场景三:调用其它 worklet 函数
    this.doSomething()
  },
  doSomething() {
    'worklet'
  },
  showModal(msg) {
    wx.showModal({
      title: msg // title: hello skyline
    })
  },
})

手势系统

业务开发中,我们常需要监听节点 touch 事件,处理拖拽、缩放相关逻辑。由于 Skyline 采用双线程架构,在进行这样的交互动画时,会具有较大的异步延迟,这点可以参考 wxs 响应事件

Skylinewxs 代码运行在 JS 线程,而事件产生在 UI 线程,因此 wxs 动画 性能有所降低,为了提升微信小程序交互体验的效果,我们内置了一批手势组件,使用手势组件的优势包括

  1. 免去开发者监听 touch 事件,自行计算手势逻辑的复杂步骤
  2. 手势组件直接在 UI 线程响应,避免了传递到 JS 线程带来的延迟

效果展示

下图演示了使用手势、协商手势实现的拖动小球,半屏弹窗手势拖动关闭,分段半屏等效果。点击查看更多 Skyline 示例。

扫码微信小程序示例,分别体验 基础手势协商手势 新特性

手势组件

组件名称 触发时机
<tap-gesture-handler> 点击时触发
<double-tap-gesture-handler> 双击时触发
<scale-gesture-handler> 多指缩放时触发
<force-press-gesture-handler> iPhone 设备重按时触发
<pan-gesture-handler> 拖动(横向/纵向)时触发
<vertical-drag-gesture-handler> 纵向滑动时触发
<horizontal-drag-gesture-handler> 横向滑动时触发
<long-press-gesture-handler> 长按时触发

工作原理

手势组件为虚组件,真正响应事件的是其直接子节点。下方代码中,我们给 container 节点添加了两种类型的手势监听。

  1. 当在屏幕上横向滑动时,horizontal-drag 手势节点的回调将被触发;
  2. 当在屏幕上纵向滑动时,vertical-drag 手势节点的回调将被触发。
<horizontal-drag-gesture-handler>
  <vertical-drag-gesture-handler>
     <view id="container"></view>
  </vertical-drag-gesture-handler>
</horizontal-drag-gesture-handler>

触摸屏幕时,渲染引擎会从内到外对手势监听器进行手势识别,当某个手势监听器满足条件时,其余的手势监听器将会失效。如在 scroll-view 内部添加纵向的手势监听时,将会阻断 scroll-view 内的手势监听器,导致无法滑动。

<scrol-view>
  <vertical-drag-gesture-handler>
     <view id="container"></view>
  </vertical-drag-gesture-handler>
</scroll-view>

需要注意的是,pan 类型的判定条件比 vertical-drag 要宽松,因此纵向滑动时,vertical-drag 将会响应,而 pan 则会失效。当横向滑动时,pan 类型则会响应。

<vertical-drag-gesture-handler>
  <pan-gesture-handler>
     <view id="container"></view>
  </pan-gesture-handler>
</vertical-drag-gesture-handler>

通用属性

属性 类型 默认值 必填 说明
tag string 声明手势协商时的组件标识
worklet:ongesture eventhandler 手势处理回调
worklet:should-response-on-move callback 手指移动过程中手势是否响应
worklet:should-accept-gesture callback 手势是否应该被识别
simultaneous-handlers Array<string> [] 声明可同时触发的手势节点
native-view string 代理的原生节点类型

native-view 支持的枚举值有 scroll-viewswiper。滚动容器纵向滚动时,使用 <vertical-drag-gesture-handler> 手势组件代理内部手势,横向滚动时,则使用 <horizontal-drag-gesture-handler>

  • eventhandler 类型是事件回调,无返回值
  • callback 类型是开发者注册到组件的回调函数,会在适当时机被执行以读取返回值
  • 所有的回调都只能传入一个 worklet 回调

事件回调参数

worklet:should-response-on-move

返回的参数 pointerEvent 各字段如下。每次触摸移动时进行回调,返回 false 时,则对应的手势组件无法收到该次 move 事件。

属性 类型 说明
identifier number Touch 对象的唯一标识符
type string 事件类型
deltaX number 相对上一次,X 轴方向移动的坐标
deltaY number 相对上一次,Y 轴方向移动的坐标
clientX number 触点相对于可见视区左边缘的 X 坐标
clientY number 触点相对于可见视区上边缘的 Y 坐标
radiusX number 返回能够包围接触区域的最小椭圆的水平轴 (X 轴) 半径
radiusY number 返回能够包围接触区域的最小椭圆的垂直轴 (Y 轴) 半径
rotationAngle number 返回一个角度值,表示上述由radiusX 和 radiusY 描述的椭圆为了尽可能精确地覆盖用户与平面之间的接触区域而需要顺时针旋转的角度
force number 用户对触摸平面的压力大小
timeStamp number 事件触发的时间戳
Page({
  shouldResponseOnMove(pointerEvent) {
    'worklet'
    return false
  }
})

worklet:should-accept-gesture

用法如下,框架手势识别生效时进行回调,由开发者决定手势是否生效。以 Pan 手势为例。

手指触摸屏幕时进入 State.Possible 状态,shouldAcceptGesture 返回 false 后进入 State.CANCELLED 状态,返回 true 后进入 State.Begin 状态,可继续接收手续 move 事件。

Page({
  shouldAcceptGesture() {
    'worklet'
    return false
  }
})

worklet:ongesture

不同类型手势组件返回的参数如下

tap / double-tap

属性 类型 说明
state number 手势状态
absoluteX number 相对于全局的 X 坐标
absoluteY number 相对于全局的 Y 坐标

pan / vertical-drag / horizontal-drag

属性 类型 说明
state number 手势状态
absoluteX number 相对于全局的 X 坐标
absoluteY number 相对于全局的 Y 坐标
deltaX number 相对上一次,X 轴方向移动的坐标
deltaY number 相对上一次,Y 轴方向移动的坐标
velocityX number 手指离开屏幕时的横向速度(pixel per second)
velocityY number 手指离开屏幕时的纵向速度(pixel per second)

scale

属性 类型 说明
state number 手势状态
focalX number 中心点相对于全局的 X 坐标
focalY number 中心点相对于全局的 Y 坐标
focalDeltaX number 相对上一次,中心点在 X 轴方向移动的坐标
focalDeltaY number 相对上一次,中心点在 Y 轴方向移动的坐标
scale number 放大或缩小的比例
horizontalScale number scale 的横向分量
verticalScale number scale 的纵向分量
rotation number 旋转角(单位:弧度)
velocityX number 手指离开屏幕时的横向速度(pixel per second)
velocityY number 手指离开屏幕时的纵向速度(pixel per second)
pointerCount number 跟踪的手指数
  • 多指滑动时,focalXfocalY 为多个触摸点中心焦点的坐标
  • 单指滑动时,pointerCount = 1,此时效果同 pan-gesture-handlerscale 手势是 pan 的超集。

long-press

属性 类型 说明
state number 手势状态
absoluteX number 相对于全局的 X 坐标
absoluteY number 相对于全局的 Y 坐标
translationX number 相对于初始触摸点的 X 轴偏移量
translationY number 相对于初始触摸点的 Y 轴偏移量
velocityX number 手指离开屏幕时的横向速度(pixel per second)
velocityY number 手指离开屏幕时的纵向速度(pixel per second)

force-press

属性 类型 说明
state number 手势状态
absoluteX number 相对于全局的 X 坐标
absoluteY number 相对于全局的 Y 坐标
pressure number 压力大小

手势状态

所有手势 worklet:ongesture 回调均会返回一个 state 状态字段。

enum State {
  // 手势未识别
  POSSIBLE = 0,
  // 手势已识别
  BEGIN = 1,
  // 连续手势活跃状态
  ACTIVE = 2,
  // 手势终止
  END = 3,
  // 手势取消
  CANCELLED = 4,
}

我们将手势分为如下两种类型:

  1. 离散手势:tapdouble-tap,仅触发一次
  2. 连续手势:其它类型的手势组件,随手指拖动会触发多次

tap-gesture-handler 手势组件返回的 state 始终为 1。

pan-gesture-handler 手势组件在一个完整的拖动过程中,state 会按如下方式改变

  1. 手指刚接触屏幕时,state = 0
  2. 移动一小段距离,pan 手势判定生效时,state = 1
  3. 继续移动,state = 2
  4. 手指离开屏幕 state = 3

由于嵌套的手势会产生冲突(仅有一个最终判定识别生效),因此连续手势 state 的变化可能有如下一些情景,开发者需要根据 state 值来处理一些异常情况。

  1. POSSIBLE -> BEGIN -> ACTIVE -> END 正常流程
  2. POSSIBLE -> BEGIN -> ACTIVE -> CANCELLED 提前中断
  3. POSSIBLE -> CANCELLED 手势未识别

并不是所有的连续手势均有 POSSIBLE 状态,如 scale-gesture-handler 手势组件,当双指触摸后松手,state 变化如下:

  1. 双指触摸屏幕,state = 1, pointerCount = 2
  2. 双指放大操作,state = 2, pointerCount = 2
  3. 双指离开屏幕,state = 3, pointerCount = 1,之后会相继回调 a. state = 1, pointerCount = 1 b. state = 2, pointerCount = 1 c. state = 3, pointerCount = 0

注意事项

  • 手势组件仅在 Skyline 渲染模式下才能使用
  • 手势组件为虚组件,不会进行布局,手势组件上设置 styleclass 是无效的
  • 手势组件仅能含有一个直接子节点,否则不生效
  • 手势组件的父组件样式会直接影响其子节点
  • 手势组件的回调函数均需声明为 worklet 函数
  • 手势不同于普通 touch 事件,不会进行冒泡
  • 手势组件的 eventhandler / callback 均需声明为 worklet 函数,回调在 UI 线程触发

使用方法

示例代码

在开发者工具中预览效果

Chaining API init 函数示例代码

在开发者工具中预览效果

示例一:监听拖动手势

<pan-gesture-handler on-gesture-event="handlePan">
  <view></view>
</pan-gesture-handler>
Page({
  handlePan(evt) {
    "worklet";
    console.log(evt.translateX);
  },
});

示例二:监听嵌套的手势

<horizontal-drag-gesture-handler on-gesture-event="handleHorizontalDrag">
  <vertical-drag-gesture-handler on-gesture-event="handleVerticalDrag">
    <view class="circle">one-way drag</view>
  </vertical-drag-gesture-handler>
</horizontal-drag-gesture-handler>

示例三:代理原生组件内部手势

对于 <scroll-view><swiper> 这样的滚动容器,它们内部也是通过手势来处理滚动操作的。和 web 相比,skyline 提供了更底层的访问机制,这样在做一些复杂交互时,可以做到更细致、分阶段的控制。

<vertical-drag-gesture-handler
  native-view="scroll-view"
  should-response-on-move="shouldScrollViewResponse"
  should-accept-gesture="shouldScrollViewAccept"
  on-gesture-event="handleGesture"
>
  <scroll-view
    scroll-y
    type="list"
    adjust-deceleration-velocity="adjustDecelerationVelocity"
    bindscroll="handleScroll"
  >
    <view class="item" wx:for="{{list}}">
      <view class="avatar" />
      <view class="comment" />
    </view>
  </scroll-view>
</vertical-drag-gesture-handler>

以纵向滚动的 <scroll-view> 为例,可以使用 <vertical-drag-gesture-handler> 这个手势组件,并设置 native-view="scroll-view" 来代理它内部的手势。

滚动事件

当滚动列表时,手势组件的事件回调和 <scroll-view>scroll 事件回调都会触发,它们的区别在于:

  1. scroll 事件只在滚动时触发,当滚动到顶部或底部后,就不再回调了
  2. on-gesture-event 手势回调在手指在屏幕上滑动时会一直触发,直到手指松开

手势控制

在前面介绍连续手势状态时,我们知道手势有自己的识别过程。例如 vertical-drag 手势,当手指触摸时是 POSSIBLE 状态,移动一小段距离后才被识别为 BEGIN 状态,这时就称为手势被识别(ACCEPT)。

1. 手势识别

should-accept-gesture 属性允许开发者注册一个 callback,并返回一个布尔值,参与到手势识别的过程中。当返回 false 时,这次触摸手势就不再生效,相关的 <scroll-view> 组件也无法滚动。

2. 事件派发

should-response-on-move 属性允许开发者注册一个 callback,并返回一个布尔值,参与到事件派发的过程中。当返回 false 时,当次的 move 事件就不再派发,相关的 <scroll-view> 也不会继续滚动。这个回调在手指移动过程中会持续触发,可以随时改变,从而控制滚动容器继续滚动或暂停滚动。

Page({
  // 这里返回 false,则 scroll-view 无法滚动
  // should-accept-gesture 会在手势识别的一开始触发一次
  // should-response-on-move 是在 move 过程中不断触发
  shouldScrollViewAccept() {
    'worklet'
    return true
  },

  // 这里返回 false,则 scroll-view 无法滚动
  shouldScrollViewResponse(pointerEvent) {
    'worklet';
    return true;
  },

  // 手指滑动离开滚动组件时,指定衰减速度
  adjustDecelerationVelocity(velocity) {
    'worklet';
   return velocity;
  },

  // scroll-view 滚动到边界后,手指滑动,scroll 事件不再触发
  handleScroll(evt) {
    'worklet';
  },

  // scroll-view 滚动到边界后,手指滑动,手势回调仍然会触发
  handleGesture(evt) {
    'worklet'
  },
});

示例四:手势协商

有些场景下,我们会遇到手势冲突。如下代码所示,存在嵌套的 <vertical-drag-gesture-handler> 组件,我们希望 outer 手势组件来处理纵向的拖动,inner 手势组件处理列表的滚动,但实际上只有 inner 的手势回调会触发。

嵌套的同类型手势组件,当内层的手势被识别后,外层的手势组件就不会被识别了。

<vertical-drag-gesture-handler tag="outer">
  <vertical-drag-gesture-handler tag="inner" native-view="scroll-view">
    <scroll-view scroll-y></scroll-view>
  </vertical-drag-gesture-handler>
</vertical-drag-gesture-handler>

但上面这种场景其实很常见,比如视频号的评论列表,列表的滚动和整个评论区的拖动衔接得非常流畅。手势协商机制就是用来解决这类问题的,使用起来也很简单,simultaneous-handlers 属性声明多个手势可以同时触发。

<vertical-drag-gesture-handler tag="outer" simultaneous-handlers="{{["inner"]}}">
  <vertical-drag-gesture-handler tag="inner" simultaneous-handlers="{{["outer"]}}" native-view="scroll-view">
    <scroll-view scroll-y></scroll-view>
  </vertical-drag-gesture-handler>
</vertical-drag-gesture-handler>

这时,outerinner 手势组件的 on-gesture-event 回调会依次触发,结合上面提到的手势控制原理,就能实现预期的效果。完整代码可以参考示例 demo

自定义路由

微信小程序采用多 WebView 架构,页面间跳转形式十分单一,仅能从右到左进行动画。而原生 App 的动画形式则多种多样,如从底部弹起,页面下沉,半屏等。

Skyline 渲染引擎下,页面有两种渲染模式: WebViewSkyline,它们通过页面配置中的 renderer 字段进行区分。在连续的 Skyline 页面间跳转时,可实现自定义路由效果。

效果展示

下方为半屏页面效果,点击可查看更多 Skyline 示例。

扫码打开微信小程序示例,交互动画 – 基础组件 – 自定义路由 即可体验。

使用方法

建议先阅读完 worklet 动画 和 手势系统 两个章节,它们是自定义路由的基础内容。

接口定义

自定义路由相关的接口

  • 页面跳转 wx.navigateTo
  • 路由上下文对象 wx.router.getRouteContext
  • 注册自定义路由 wx.router.addRouteBuilder
type AddRouteBuilder = (routeType: string, routeBuilder: CustomRouteBuilder) => void

type CustomRouteBuilder = (routeContext: CustomRouteContext, routeOptions: Record<string, any>) => CustomRouteConfig

interface SharedValue<T> {
  value: T;
}

interface CustomRouteContext {
  // 动画控制器,影响推入页面的进入和退出过渡效果
  primaryAnimation: SharedValue<number>
  // 动画控制器状态
  primaryAnimationStatus: SharedValue<number>
  // 动画控制器,影响栈顶页面的推出过渡效果
  secondaryAnimation: SharedValue<number>
  // 动画控制器状态
  secondaryAnimationStatus: SharedValue<number>
  // 当前路由进度由手势控制
  userGestureInProgress: SharedValue<number>
  // 手势开始控制路由
  startUserGesture: () => void
  // 手势不再控制路由
  stopUserGesture: () => void
  // 返回上一级,效果同 wx.navigateBack
  didPop: () => void
}

interface CustomRouteConfig {
  // 下一个页面推入后,不显示前一个页面
  opaque?: boolean;
  // 是否保持前一个页面状态
  maintainState?: boolean;
  // 页面推入动画时长,单位 ms
  transitionDuration?: number;
  // 页面推出动画时长,单位 ms
  reverseTransitionDuration?: number;
  // 遮罩层背景色,支持 rgba() 和 #RRGGBBAA 写法
  barrierColor?: string;
  // 点击遮罩层返回上一页
  barrierDismissible?: boolean;
  // 无障碍语义
  barrierLabel?: string;  
  // 是否与下一个页面联动,决定当前页 secondaryAnimation 是否生效
  canTransitionTo?: boolean;
  // 是否与前一个页面联动,决定前一个页 secondaryAnimation 是否生效
  canTransitionFrom?: boolean;
  // 处理当前页的进入/退出动画,返回 StyleObject
  handlePrimaryAnimation?: RouteAnimationHandler;
  // 处理当前页的压入/压出动画,返回 StyleObject
  handleSecondaryAnimation?: RouteAnimationHandler;
  // 处理上一级页面的压入/压出动画,返回 StyleObject 基础库 <3.0.0> 起支持
  handlePreviousPageAnimation?: RouteAnimationHandler;
  // 页面进入时是否采用 snapshot 模式优化动画性能 基础库 <3.2.0> 起支持
  allowEnterRouteSnapshotting?: boolean
  // 页面退出时是否采用 snapshot 模式优化动画性能 基础库 <3.2.0> 起支持
  allowExitRouteSnapshotting?: boolean
  // 右滑返回时,可拖动范围是否撑满屏幕,基础库 <3.2.0> 起支持,常用于半屏弹窗
  fullscreenDrag?: boolean
  // 返回手势方向 基础库 <3.4.0> 起支持
  popGestureDirection?: 'horizontal' | 'vertical' | 'multi'
}

type RouteAnimationHandler = () => { [key: string] : any}

默认路由配置

const defaultCustomRouteConfig = {
  opaque: true,
  maintainState: true,
  transitionDuration: 300,
  reverseTransitionDuration: 300,
  barrierColor: '',
  barrierDismissible: false,
  barrierLabel: '',
  canTransitionTo: true,
  canTransitionFrom: true,
  allowEnterRouteSnapshotting: false,
  allowExitRouteSnapshotting: false,
  fullscreenDrag: false,
  popGestureDirection: 'horizontal'
}

示例模板

以下是注册自定义路由的一份示例模板(未添加手势处理部分),完整实现半屏路由效果见示例代码。

const customRouteBuiler = (routeContext: CustomRouteContext) : CustomRouteConfig => {
  const {
    primaryAnimation,
    secondaryAnimation,
    userGestureInProgress
  } = routeContext

  const handlePrimaryAnimation: RouteAnimationHandler = () => {
    'worklet'
    let t = primaryAnimation.value
    if (!userGestureInProgress.value) {
      // select another curve, t = xxx
    }
    // StyleObject
    return {}
  }

  const handleSecondaryAnimation: RouteAnimationHandler = () => {
    'worklet'
    let t = secondaryAnimation.value
    if (!userGestureInProgress.value) {
      // select another curve, t = xxx
    }
    // StyleObject
    return {}
  }

  return {
    opaque: true,
    handlePrimaryAnimation,
    handleSecondaryAnimation
  }
}

// 在页面跳转前定义好 routeBuilder
wx.router.addRouteBuilder('customRoute', customRouteBuiler)

// 跳转新页面时,指定对应的 routeType
wx.navigateTo({
  url: 'xxxx',
  routeType: 'customRoute'
})

工作原理

以半屏效果为例,路由前后页面记为 A 页、B 页,一个路由的生命周期中,会经历如下阶段:

  1. push 阶段 :调用 wx.navigateToB 页自底向上弹出,A 页下沉收缩
  2. 手势拖动:在 B 页上下滑动时,路由动画随之变化
  3. pop 阶段 :调用 wx.navigateBackB 页向下关闭,A 恢复原样

细分到每个页面,在上述阶段会有以下动画方式

  1. 进入/退出动画
  2. 压入/压出动画
  3. 手势拖动
  • push 阶段,B 页进行的是进入动画,A 页进行的是压入动画;
  • pop 阶段,B 页进行的是退出动画,A 页进行的是压出动画;

可以看到在路由过程中,前后两个页面动画进行了联动。在自定义路由模式下,我们可以对动画各个阶段的时长、曲线、效果以及是否联动进行自定义,以实现灵活多变的页面专场效果。

路由控制器

当打开新页面时,框架会为其创建两个 SharedValue 类型的动画控制器 primaryAnimationsecondaryAnimation,分别控制进入/退出动画和压入/压出动画。

页面的进入和退出可指定不同的时长,但进度变化始终在 0~1 之间。仍以半屏效果为例,路由前后页面记为 A 页、B 页。

push 阶段

  1. B 页对应的 primaryAnimation0 -> 1 变化,做进入动画
  2. A 页对应的 secondaryAnimation0 -> 1 变化,做压入动画

pop 阶段

  1. B 页对应的 primaryAnimation1 -> 0 变化,做退出动画
  2. A 页对应的 secondaryAnimation1 -> 0 变化,做压出动画

其中,AsecondaryAnimation 的值始终与 BprimaryAnimation 的值同步变化。

通常页面的进入和退出可能采用不同的动画曲线,可通过对应的状态变量 primaryAnimationStatussecondaryAnimationStatus 来区分当前处于哪一阶段,ts 定义如下

enum AnimationStatus {
  // 动画停在起点
  dismissed = 0,
  // 动画从起点向终点进行
  forward = 1,
  // 动画从终点向起点进行
  reverse = 2,
  // 动画停在终点
  completed = 3,
}

primaryAnimationStatus 为例,页面进入和退出过程中变化情况如下

  1. push 阶段:dismissed -> forward -> completed
  2. pop 阶段:completed -> reverse -> dismissed

路由手势

在页面推入后,除了调用 wx.navigateBack 接口返回上一级外,还可以通过手势来处理,例如 iOS 上常见的右滑返回。自定义路由模式下,开发者可根据不同的页面转场效果,来选取所需的退出方式,如半屏效果可采用下滑返回。关于手势监听的内容,可参考 手势系统 一章,路由手势仅是在其基础上,补充了几个路由相关的接口。

startUserGesturestopUserGesture 两个函数总是成对调用的,startUserGesture 调用后 userGestureInProgress 的值会加 1

当开发者自行修改 primaryAnimation 的值来控制路由进度的时候,就需要调用这两个接口。由于手势拖动过程中通常采用不同的动画曲线,可通过 userGestureInProgress 值进行判断。

当手势处理后确定需要返回上一级页面时,调用 didPop 接口,作用等同 wx.navigateBack

路由联动

路由动画过程中,默认前后两个页面是一起联动的,可通过配置项关闭。

  1. canTransitionTo:是否与下一个页面联动,栈顶页面该属性置为 false ,推入下一页面时,则栈顶页面始终不动
  2. canTransitionFrom:是否与前一个页面联动,新推入页面该属性置为 false,则栈顶页面始终不动

路由上下文对象

由示例模版可见,自定义路由的动画效果就是根据 CustomRouteContext 上下文对象上的路由控制器,编写适当的动画更新函数来实现。

CustomRouteContext 上下文对象还可在页面/自定义组件中通过 wx.router.getRouteContext(this) 读取,进而在手势处理过程中访问,通过对 primaryAnimation 值的改写实现页面手势返回。

小技巧:可在 CustomRouteContext 对象上添加一些私有属性,在页面中进行读取/修改。

多类型路由跳转

考虑这样的场景,从页面 A 可能跳转到 B 页和 C 页,但具有不同的路由动画

  1. A -> B 时,希望实现半屏效果,A 需要下沉收缩
  2. A -> C 时,希望采用普通路由,A 需要向左移动

跳转下一级页面时的动画由 handleSecondaryAnimation 控制,这样就需要在定义 ACustomRouteBuilder 时考虑所有的路由类型,实现较为繁琐。

基础库 3.0.0 版本起,自定义路由新增 handlePreviousPageAnimation 接口,用于控制上一级页面的压入/压出动画。

const customRouteBuiler = (routeContext: CustomRouteContext) : CustomRouteConfig => {
  const { primaryAnimation } = routeContext

  const handlePrimaryAnimation: RouteAnimationHandler = () => {
    'worklet'
    let t = primaryAnimation.value
    // 控制当前页的进入和退出
  }

  const handlePreviousPageAnimation: RouteAnimationHandler = () => {
    'worklet'
    let t = primaryAnimation.value
    // 控制上一级页面的压入和退出
  }

  return {
    handlePrimaryAnimation,
    handlePreviousPageAnimation
  }
}

A 跳转到 B 时, AsecondaryAnimation 的值始终与 BprimaryAnimation 的值同步变化。

我们可以在定义 BCustomRouteBulder 时,通过 primaryAnimation 得知当前路由进度,handlePreviousPageAnimation 返回的 StyleObject 会作用于上一级页面。

同时也不再需要提前声明 A 为自定义路由,在此之前 A 跳转 B 希望实现半屏效果时,A 也必须定义为自定义路由。

完整的示例可参考如下代码,借助 handlePreviousPageAnimation 可去掉对 secondaryAnimation 的依赖,简化代码逻辑。

在开发者工具中预览效果

实际案例

下面以半屏效果为例,讲解自定义路由的具体实现过程,完整代码见示例代码。

路由前后页面分别记为 A 页和 B 页,需要分别为其注册自定义路由。未注册任何自定义路由效果时,新打开的页面 B 会立即覆盖显示在 A 页上。

Step-1 页面进入动画

我们先分别简单实现 首页 -> A 页 -> B 页的进入动画,再一步步进行完善。

对于 A 页面,进入方式为自右向左,通过 transform 平移实现。

function ScaleTransitionRouteBuilder(customRouteContext) {
  const {
    primaryAnimation
  } = customRouteContext

  const handlePrimaryAnimation = () => {
    'worklet'
    let t = primaryAnimation.value
    const transX = windowWidth * (1 - t)
    return {
      transform: `translateX(${transX}px)`,
    }
  }
  return {
    handlePrimaryAnimation
  }
}

对于 B 页面,进入方式为自底向上,也是通过 transform 平移实现,但需要对页面大小、圆角进行修改。

const HalfScreenDialogRouteBuilder = (customRouteContext) => {
  const {
    primaryAnimation,
  } = customRouteContext

  const handlePrimaryAnimation = () => {
    'worklet'
    let t = primaryAnimation.value
    // 距离顶部边距因子
    const topDistance = 0.12
    // 距离顶部边距
    const marginTop = topDistance * screenHeight
    // 半屏页面大小
    const pageHeight = (1 - topDistance) * screenHeight
    // 自底向上显示页面
    const transY = pageHeight * (1 - t)
    return {
      overflow: 'hidden',
      borderRadius: '10px',
      marginTop: `${marginTop}px`,
      height: `${pageHeight}px`,
      transform: `translateY(${transY}px)`,
    }
  }

  return {
    handlePrimaryAnimation,
  }
}

页面跳转效果如下,可以看到由于采用线性曲线(未对 t 做任何变换),动画有些呆板,同时未区分进入/退出动画。在 B 页完全进入后,A 页变的不可见。

Step-2 自定义动画曲线

B 页为例,根据 AnimationStatus 值,采用不同的动画曲线,同时设置 opaquefalse,使得路由动画完成后仍显示 A 页面。

const { Easing, derived } = wx.workelt

const Curves = {
  linearToEaseOut: Easing.cubicBezier(0.35, 0.91, 0.33, 0.97),
  easeInToLinear: Easing.cubicBezier(0.67, 0.03, 0.65, 0.09),
  fastOutSlowIn: Easing.cubicBezier(0.4, 0.0, 0.2, 1.0),
  fastLinearToSlowEaseIn: Easing.cubicBezier(0.18, 1.0, 0.04, 1.0),
}

function CurveAnimation({ animation, animationStatus, curve,reverseCurve }) {
  return derived(() => {
    'worklet'
    const useForwardCurve = !reverseCurve || animationStatus.value !== AnimationStatus.reverse
    const activeCurve = useForwardCurve ? curve : reverseCurve
    const t = animation.value
    if (!activeCurve) return t
    if (t === 0 || t === 1) return t
    return activeCurve(t)
  })
}
const HalfScreenDialogRouteBuilder = (customRouteContext) => {
  const {
    primaryAnimation,
    primaryAnimationStatus,
  } = customRouteContext

  // 1. 页面进入时,采用 Curves.linearToEaseOut 曲线
  // 2. 页面退出时,采用 Curves.easeInToLinear 曲线
  const _curvePrimaryAnimation = CurveAnimation({
    animation: primaryAnimation,
    animationStatus: primaryAnimationStatus,
    curve: Curves.linearToEaseOut,
    reverseCurve: Curves.easeInToLinear,
  })

  const handlePrimaryAnimation = () => {
    'worklet'
    let t = _curvePrimaryAnimation.value
    ... // 其余内容等上面的代码一致
  }

  return {
    opaque: false,
    handlePrimaryAnimation,
  }
}

这里的区别仅在于,当前的进度不再直接读取 primaryAnimation 的值。封装的 CurveAnimation 函数会根据 AnimationStatus 判断是处于进入还是退出状态,从而选择不同的动画曲线。微信小程序框架提供了多种曲线类型,可进一步参考 worklet.Easing。改进后的页面转场效果如下

Step-3 页面联动效果

B 页进入时,A 页作压入动画,由 secondaryAnimation 控制。接下来,我们为其添加下沉效果,实现和 B 页的联动。

function ScaleTransitionRouteBuilder(customRouteContext) {
  const {
    primaryAnimation
  } = customRouteContext

  const handlePrimaryAnimation = () => {
    'worklet'
    ...
  }

  const _curveSecondaryAnimation = CurveAnimation({
    animation: secondaryAnimation,
    animationStatus: secondaryAnimationStatus,
    curve: Curves.fastOutSlowIn,
  })

  const handleSecondaryAnimation = () => {
    'worklet'
    let t = _curveSecondaryAnimation.value
    // 页面缩放大小
    const scale = 0.08
    // 距离顶部边距因子
    const topDistance = 0.1
    // 估算的偏移量
    const transY = screenHeight * (topDistance - 0.5 * scale) * t 
    return {
      overflow: 'hidden',
      borderRadius: `${ 12 * t }px`,
      transform: `translateY(${transY}px) scale(${ 1 - scale * t })`,
    }
  }

  return {
    handlePrimaryAnimation,
    handleSecondaryAnimation
  }
}

通过对 A 页作 scaletranslate 变换实现下沉效果。AsecondaryAnimation 的值始终与 BprimaryAnimation 的值保持同步。

页面是否联动还可通过 canTransitionTocanTransitionFrom 两个属性进行配置,可在开发者工具上修改体验。

Step-4 手势返回

目前动画效果已经基本实现,还需要最后一步,手势返回。对于半屏效果,我们为 A 页添加右滑返回手势,B 页添加下滑返回手势。

以最常见的右滑返回为例,这里只截取松手后的手势处理部分代码,拖动过程实现较为简单,可参考示例代码。

page({
  handleDragEnd(velocity) {
    'worklet';
    const {
        primaryAnimation,
        stopUserGesture,
        didPop
    } = this.customRouteContext;

    let animateForward = false;
    if (Math.abs(velocity) >= 1.0) {
      animateForward = velocity <= 0;
    } else {
      animateForward = primaryAnimation.value > 0.5;
    }
    const t = primaryAnimation.value;
    const animationCurve = Curves.fastLinearToSlowEaseIn;
    if (animateForward) {
        const duration = Math.min(
          Math.floor(lerp(300, 0, t)),
          300,
        );
        primaryAnimation.value = timing(
          1.0, {
            duration,
            easing: animationCurve,
          },
          () => {
            'worklet'
            stopUserGesture();
          },
        );
    } else {
      const duration = Math.floor(lerp(0, 300, t));
      primaryAnimation.value = timing(
        0.0, {
          duration,
          easing: animationCurve,
        },
        () => {
          'worklet'
          stopUserGesture();
          didPop();
        },
      );
    }
  },
})

首先根据松手时的速度和位置,决定是否要真正返回上一级。

  1. 向右滑动且速度大于 1
  2. 或者速度较小时,已拖动超过屏幕 1/2

满足以上条件时,确定返回。通过 timing 接口,为 primaryAnimation 添加过渡动画,使其变化到 ,最后调用 didPop 。否则使其变化到 1,恢复到拖动前的状态。

这里需要注意的是,当需要对 primaryAnimation 值手动修改,自由掌控其过渡方式时,才需要调用 startUserGesturestopUserGesture 接口。

右滑手势已经在示例代码中封装成 swipe-back 组件,开发者可直接使用。下滑手势返回逻辑基本一致,仅一些数值上略有差异。

最后的实现效果如图

设置页面透明

一些自定义路由效果下,需要实现页面透明背景,这里对 Skylinewebview 模式下背景色的层级关系进行说明。

自定义路由下的页面背景色

Skyline 模式下使用自定义路由方式跳转页面,页面背景色有如下几层

  1. 页面背景色:可通过 page 选择器在 wxss 中定义,默认为白色
  2. 页面容器背景色:可在页面 json 文件中通过 backgroundColorContent 属性定义,支持 #RRGGBBAA 写法,默认白色
  3. 自定义路由容器背景色,由路由配置项中返回的 StyleObject 控制,默认透明
  4. 控制是否显示前一个页面,由路由配置项中的 opaque 字段控制,默认不显示

当需要设置下一个页面渐显进入时,可简单设置

  1. 页面背景色透明: page { background-color: transparent; }
  2. 页面容器背景色透明: backgroundColorContent: "#ffffff00"

查看自定义路由页面渐显示例

webview 下的页面背景色

对比看下,webview 模式下的页面背景色

  1. 页面背景色:可通过 page 选择器在 wxss 中定义,默认为透明
  2. 页面容器背景色:可在页面 json 文件中通过 backgroundColorContent 属性定义,支持 #RRGGBB 写法,默认白色
  3. 窗口背景色:可通过 wx.setBackgroundColor 接口或页面配置修改,默认为白色

示例代码

在开发者工具中预览效果

预设路由

为了降低开发成本,基础库预设了一批常见的路由动画效果。

routeType 最低基础库版本
wx://bottom-sheet 3.1.0
wx://upwards 3.1.0
wx://zoom 3.1.0
wx://cupertino-modal 3.1.0
wx://cupertino-modal-inside 3.1.0
wx://modal-navigation 3.1.0
wx://modal 3.1.0

使用方法

只需要在路由跳转时,指定对应的 routeType 即可。

注意:以上路由效果都可以通过自定义路由来实现,可以参考示例代码中的源码文件,自定义你需要的效果。

wx.navigateTo({
  url: 'xxx',
  routeType: 'wx://modal'
})

示例代码

在开发者工具中预览效果

效果演示

wx://bottom-sheet
半屏弹窗
wx://upwards
向上进入
wx://zoom
放大进入
wx://cupertino-modal-inside
从 wx-cupertino-modal 跳转到 wx-cupertino-modal-inside
wx://modal-navigation
从 wx-cupertino-modal 跳转到 wx-modal-navigation
wx://modal
从 wx-modal 跳转到 wx-modal-navigation

容器转场动画

通过将一个元素无缝地转换为另一个元素,可以加强两个元素间的关系,比如常见的瀑布流中点击卡片跳转到详情页。

为了降低开发成本,基础库提供了容器转场动画组件来实现这个路由效果。

效果演示

使用方法

开发者工具需要升级到 Nightly 1.06.2403222,基础库选择 3.4.0

将要进行过渡的元素放在 <open-container> 组件内,点击 <open-container>,当使用 navigateTo 跳转到下一个页面时,会对它的子节点和下一个页面进行过渡。

<open-container
  closed-elevation="{{closedElevation}}"
  closed-border-radius="{{closedBorderRadius}}"
  open-elevation="{{openElevation}}"
  open-border-radius="{{openBorderRadius}}"
  transition-type="{{type}}"
  transition-duration="{{duration}}"
  bind:tap="goDetail"
>
  <card/>
</open-container>
Page({
   goDetail() {
    wx.navigateTo({
      url: 'nextPageUrl'
    })
  }
})

组件属性

属性 类型 默认值 必填 说明
closed-color string white 初始容器背景色
closed-elevation number 0 初始容器影深大小
closed-border-radius number 0 初始容器圆角大小
middle-color string fadeThrough 模式下的过渡背景色
open-color string white 打开状态下容器背景色
open-elevation number 0 打开状态下容器影深大小
open-border-radius number 0 打开状态下容器圆角大小
transition-duration number 300 动画时长
transition-type string fade 动画类型

示例代码片段

在开发者工具中预览效果

页面返回手势

默认情况下,微信小程序页面都是右滑返回。但在使用自定义路由和预设路由时,我们常常需要不同的手势返回效果。

例如使用 wx://cupertino-modal 路由效果时,下个页面自底向上出现,右滑返回并不符合视觉一致性。采用纵向的滑动返回(原路返回)会更合适一些。

使用方法

开发者工具需升级到 Nightly 1.06.2403222,基础库选择 3.4.0

一行代码配置

在自定义路由配置中,开发者可通过 fullscreenDragpopGestureDirection 来定义手势返回效果。

属性 类型 默认值 说明
popGestureDirection string horizontal 返回手势方向
fullscreenDrag boolean false 右滑返回手势区域拓展到全屏范围

popGestureDirection 支持的枚举值如下

  • horizontal:仅能横向拖动返回,fullscreenDrag 仅对横向拖动有效
  • vertical: 仅能纵向拖动返回
  • multi: 可以横向或纵向拖动返回

结合纵向滚动容器

当纵向拖动返回时,若页面内有纵向滚动的 <scroll-view>,默认在 scroll-view 上滑动无法触发页面返回。

此时可声明关联容器为 pop-gesture,此时滑动 scroll-view 至顶端后可继续触发页面返回。

<scroll-view 
  type="custom"
  associative-container="pop-gesture"
>
  <!-- 页面内容 -->
</scroll-view>

结合预设路由

为增加路由配置的灵活性,3.4.0 版本起 wx.navigateTo 增加 routeConfigrouteOptions 两个属性。

routeConfig

routeConfig 可配字段navigateTo 传入的 routeConfig 将会覆盖 routeBuilder 返回的配置项,开发者可借此更改 预设路由 返回手势类型。

routeOptions

routeBuilder 接口定义routeOptions 将作为 routeBuilder 的第二个参数传入,开发者可根据当前页面动态改变路由动画的内容。比如对 BottomSheet 更改高度、圆角等,以适应不同场景。

interface INavigateToArg {
  url: string,
  routeType: string,
  routeConfig: CustomRouteConfig,
  routeOptions: Record<string, any>
}

wx.navigateTo({
  routeType: 'wx://bottom-sheet',
  routeConfig: {
    fullscreenDrag: true,
    popGestureDirection: 'multi'
  },
  routeOptions: {
    round: false,
  },
})

常用的 wx://bottom-sheet 预设路由 routeOptions 增加如下属性

属性 类型 默认值 说明
round boolean true 是否使用圆角
height number 60 弹窗页面高度,单位 vh

示例代码片段

在开发者工具中预览效果

共享元素动画

原生 App 中我们常见到这样的交互,如从商品列表页进入详情页过程中,商品图片在页面间飞跃,使得过渡效果更加平滑,另一个案例是朋友圈的图片预览放大功能。在 Skyline 渲染模式下,我们称其为共享元素动画,可通过 share-element 组件来实现。

在连续的 Skyline 页跳转时,页面间 key 相同的 share-element 节点将产生飞跃特效,开发者可自定义插值方式和动画曲线。通常作用于图片,为保证动画效果,前后页面的 share-element 子节点结构应该尽量保持一致。

立即体验

扫码打开微信小程序示例,交互动画 – 基础组件 – 共享元素动画 即可体验。

使用方法

属性 类型 默认值 必填 说明 最低版本
key string 映射标记,页面内唯一 2.29.2
transition-on-gesture boolean false 手势返回时是否进行动画 2.29.2
shuttle-on-push string to 指定 push 阶段的飞跃物 2.30.2
shuttle-on-pop string to 指定 pop 阶段的飞跃物 2.30.2
rect-tween-type string materialRectArc 动画插值曲线 2.30.2
on-frame worklet callback 动画帧回调 2.30.2

假定 A 页和 B 页存在对应的 share-element 组件

  1. push 阶段:通过 wx.navigateToA 进入 B,称 A 为源页面(from 页),B 为目标页(to 页)
  2. pop 阶段:通过 wx.navigateBackB 返回 A,此时 B 为源页面 (from 页), A 为目标页(to 页)

指定飞跃物

开发者可以指定选定源页面或目标页的 share-element 作为飞跃物。

由于涉及两个页面的组件,这里以目标页 share-element 组件指定的属性为准

  1. push 阶段:默认采用 B 页的 share-element 组件进行飞跃,设置属性 shuttle-on-push=from 可切换成 A 页的。
  2. pop 阶段:默认采用 A 页的 share-element 组件,设置属性 shuttle-on-pop=from 可切换成 B 页的。

需注意的是 on-frame 回调总是在指定为飞跃物的 share-element 组件上触发。

动画帧回调

共享元素动画就是以源页面 share-element 所在矩形框为起点,目标页 share-element 所在矩形框为终点,进行插值计算的过程,动画时长与页面路由时间一致。

enum FlightDirection {
  push = 0,
  pop = 1
}

interface Rect {
  top: number,
  right: number,
  bottom: number,
  left: number,
  width: number,
  height: number
}

interface ShareElementFrameData {
  // 动画进度,0~1
  progress: number,
  // 动画方向,push | pop
  direction: FlightDirection
  // 源页面 share-element 容器尺寸
  begin: Rect,
  // 目标页 share-element 容器尺寸
  end: Rect,
  // 由框架计算的当前帧飞跃物容器尺寸
  current: Rect,
}

type ShareElementOnFrameCallback = (data: ShareElementFrameData) => undefined | Rect

开发者可通过 on-frame 回调来自定义插值方式。ShareElementFrameData 中包含了始末位置,以及框架按照指定的动画曲线 rect-tween-type 和当前进度 progress 计算的 current 位置。

默认插值方式为对 RectLTWH 分别进行线性插值。当 on-frame 返回 undefined 时,当前帧飞跃物的实时位置由 current 决定,开发者可返回按其他方式计算的 Rect 对象进行改写。

const lerp = (begin: number, end: number, t: number) => {
  'worklet'
  return begin + (end - begin) * t
}

const lerpRect = (begin: Rect, end: Rect, t: number) => {
  'worklet'
  const left = lerp(begin.left, end.left, t);
  const top = lerp(begin.top, end.top, t);
  const width = lerp(begin.width, end.width, t);
  const height = lerp(begin.height, end.height, t);
  const right = left + width
  const bottom = top + height

  return {
    left,
    top,
    right,
    bottom,
    width,
    height
  }
}

动画插值曲线

除了可以自定义插值方式外,还可以自定义动画曲线,默认的动画曲线为 cubic-bezier(0.4, 0.0, 0.2, 1.0),记作 fastOutSlowIn

rect-tween-type 设置为如下类型时,默认的插值方式为对 RectLTWH 分别进行线性插值

  • linear
  • elasticIn
  • elasticOut
  • elasticInOut
  • bounceIn
  • bounceOut
  • bounceInOut
  • cubic-bezier(x1, y1, x2, y2)

此外,rect-tween-type 还支持两类特殊的枚举值,对于这两个值,动画曲线仍是 fastOutSlowIn,但插值方式有所不同,运动轨迹为弧线。

  • materialRectArc:矩形对角动画
  • materialRectCenterArc:径向动画

工作原理

下面以 push 过程为例介绍共享元素动画的各个阶段。

注意:实际上动画过程中 share-element 节点自身不进行动画,移动的是其子节点。

动画开始前

目标页还未渲染,在所有页面之上创建 overlay 层,飞跃物将在 overlay 层进行动画。

动画开始时刻

调用 wx.navigateTo 推入新页面时动画触发,progress = 0 时刻发生如下动作

  1. 目标页首帧渲染,框架计算出目标 share-element 节点位置大小。
  2. share-element 节点离屏(不显示)。
  3. 将目标 share-element 节点移至 overlay 层,作为飞跃物,位置大小同源 share-element 节点。

动画过程中

根据指定的插值方式和动画曲线,目标 share-elementoverlay 层进行动画,从起点向终点位置过渡。

动画结束时刻

progress = 1 时刻,将目标 share-elementoverlay 层移动到目标页面,出现在终点位置。

注意事项

  • Skyline 版本 share-element 无需结合 page-container 使用
  • share-element 组件设置 paddingjustify-content 等影响子节点布局的样式将无法生效
  • 可结合页面生命周期 onRouteDone 在路由完成时刻做一些状态恢复工作

Q & A

Q1: 设置了相同的 key,但没有看到飞跃动画

共享元素动画需保证下一个页面首帧即创建好 share-element 节点,并设置了 key,用于计算目标位置。

如果是通过 setData 设置的,可能会错过首帧。针对这种情况,可以 使用 Component 构造器构造下一个页面,只要在组件 attached 生命周期前(含)通过 setData 设置上去,就会在首帧渲染。

Q2: 飞跃过程中,子节点不会跟随放大/缩小

动画过程中,飞跃物容器会不断变化大小和位置,如果子节点想自适应跟随变化,就需要通过百分比布局,而非写死固定宽高。

<!-- 由于子 view 固定大小,飞跃过程中仅位置发生变化,大小不变 -->
<share-element key="portrait">
  <view style="width: 50px; height: 50px;"></view>
</share-element>

<!-- 由于子 view 设置跟父节点一样大,飞跃过程中位置、大小均会改变 -->
<share-element key="portrait" style="width: 50px; height: 50px;">
  <view style="width: 100%; height: 100%;"></view>
</share-element>

Q3: 多个 share-element 一起动画,覆盖层级问题

飞跃物在 overlay 层进行动画,层级在所有页面之上。飞跃物间按其在页面组件树的定义顺序(DFS 遍历),越往后的层级越高。

Q4: 自定义路由手势返回时,未看到返回动画

首先须给组件设置 transition-on-gesture=true 属性。同时自定义路由手势返回时,只有调用 startUserGesture 接口后才会触发共享元素动画。

Q5: 共享元素动画与路由动画关系

共享元素跟随页面路由动画一同开始和结束。如果设置自定义路由的页面进入曲线和 rect-tween-type 一致,则 onFrame 返回的 progress 值也与 PrimaryAnimation.value 的值始终保持一致。

示例用法

基础用法

商品列表页

<block wx:for="{{list}}" wx:key="id">
  <share-element key="box" transition-on-gesture>
    <image
    src="{{src}}"
    mode="aspectFill"
    />
  </share-element>
</block>

商品详情页

<share-element key="box" transition-on-gesture>
  <image
  src="{{src}}"
  mode="aspectFit"
  />
</share-element>

示例代码片段-基础

可在开发者工具中体验效果,这里需要注意图片 mode 不同带来的影响。

进阶用法

以仿朋友圈图片预览放大功能为例,介绍通过帧回调解决图片 mode 不同带来的跳变问题。相关功能已封装成 aniamted-image 组件,开发者可在此基础上进行修改。

这里简要介绍核心思路:

  1. 始终以 listshare-element 为飞跃物
  2. 通过 on-frame 改写飞跃物容器的位置大小,使其充满 overlay
  3. 以图片实际占据空间确定始末容器的位置大小,而不是 share-element 节点占据的空间
  4. 飞跃过程中使用缩略图,高清图下载完成后进行替换

默认计算方式,以 share-element 节点占据空间确定始末位置,会产生跳变。

以图片实际占据空间确定始末位置,始终采用单一 mode 效果,不会产生跳变。

使用最新 nightly 工具预览,移动端安卓 8.0.33 版本。

示例代码片段-进阶

全局工具栏

基础库 3.3.1 开始支持,低版本需做兼容处理。

可跨页面渲染的组件,使用场景如音乐 APP 的底部工具栏等。在连续的 Skyline 页面跳转时,组件实例为同一个,因此状态可以同步,渲染层级在页面之上(也在自定义 tabbar 之上)。

webview 渲染和 Skyline 渲染之间混跳时,有如下限制:

  1. app-bar 组件仅支持 Skyline 渲染,从 webview 页跳 Skyline 页才会出现,返回到 webview 页则消失。
  2. 连续的 Skyline 页面间跳转,app-bar 组件为同一实例,中间若隔着 webview,则为不同实例,状态不同步。
  3. Skyline 返回到 webview 页面,再次进入 Skylineapp-bar 组件实例会重建,状态也不同步。

示例代码

开发者工具需升级到 Nightly 1.06.2401052,基础库选择 3.3.1

示例-1 混跳场景

在开发者工具中预览效果

示例-2 音乐类播放栏

在开发者工具中预览效果

使用流程

1. 配置信息

  • app.json 中添加 appBar 选项。

示例:

{
  "appBar": {}
}

2. 添加 appBar 代码文件

在代码根目录下添加入口文件,注意不要修改文件命名,app-bar 组件不可声明为虚拟化节点 virtualHost: true

app-bar/index.js
app-bar/index.json
app-bar/index.wxml
app-bar/index.wxss

3. 编写 appBar 代码

用自定义组件的方式编写即可,该自定义组件完全接管 appBar 的渲染。另外,自定义组件新增 getAppBar 接口,可获取当前页面下的 appBar 组件实例。

1. 响应事件

为防止遮挡页面,app-bar 组件根节点默认添加了 pointer-events: none;,组件内的节点需响应点击时,需加上 pointer-events: auto;

<view class="tool-bar"></view>
.tool-bar {
  pointer-events: auto;
  position: absolute;
}

2. 获取组件实例

Page({
  getInstance() {
    if (typeof this.getAppBar === 'function' ) {
      const appBarComp = this.getAppBar()
      appBarComp.setData({})
    }
  }
})

滚动容器及其应用场景

流畅的滚动对于提升用户体验至关重要。为了达到原生级别的滚动效果和降低开发成本,Skyline 扩展了旧的 ScrollView 组件,同时针对部分场景,新增了一些滚动容器。诸多的新能力有时会让开发者选择困难,下面对其典型应用场景进行介绍。

长列表

WebView 下的 ScrollView 组件,在快速滑动时容易出现白屏和渲染卡顿。对于长列表的优化,通常离不开按需渲染,即理想状态下仅渲染在屏节点,超出可视区域的节点及时进行回收。

Skyline 下内置了按需渲染的能力,但对于写法有一定要求,列表项需作为直接子节点,形如下面的结构。

<scroll-view type="list" scroll-y>
  <view> a </view>
  <view> b </view>
  <view> c </view>
</scroll-view>

视口大小

ScrollView 的视口大小 = ScrollView 的高度 + 指定的上下缓冲区 CacheExtent

指定 CacheExtent 可优化滚动体验和加载速度,但会提高内存占用且影响首屏速度,可按需启用。

节点进入视口区域时开始渲染,离开视口时回收资源。资源回收的粒度为其直接子节点。当 ScrollView 仅有单个子节点时,为保证其渲染,所有的资源都无法回收,需全量布局和绘制所有内容,性能较差,因此才需要摊平子节点。

出于业务需要 ScrollView 的内容常被封装成组件,导致无法作为直接子节点。这里有一个小技巧,可将封装的组件设为虚拟的,开启 virtualHost: true。真正渲染时,virtual-comp 节点并不存在,列表项仍是摊平的。

<scroll-view type="list" scroll-y>
  <virtual-comp>
    <view> a </view>
    <view> b </view>
    <view> c </view>
  </virtual-comp>
</scroll-view>

完全的按需渲染

微信小程序内的按需渲染分为两个阶段。

  1. 列表项按需创建其 DOM 节点
  2. 列表项按需绘制上屏;

ScrollViewlist 模式实现了按需绘制,但列表项的 DOM 节点 仍是全量创建的。随着节点数增多,会带来内存压力。

为此框架提供了新的 builder 模式,可使用 list-buildergrid-builder 等组件实现 DOM 节点 的按需创建。

上图是 builder 模式在开发者工具中 wxml 的渲染结果,仅在屏列表项会被真正创建节点,离屏后列表项会被回收,滚动时始终几个子节点。

示例代码片段

在开发者工具中预览效果

ScrollView 的三种模式

ScrollView 提供了列表 list、自定义 custom 和 嵌套 nested 三种渲染模式,实际开发时如何选择呢?

列表模式

默认模式,实现了内置的按需渲染能力,但没有进行节点回收。当列表项比较简单,不会带来明显的内存压力时使用。

非长列表时,即使不摊平列表项也不会有明显性能问题,可使用单子节点写法。

<!-- 单子节点写法,全量绘制 -->
<scroll-view type="list" scroll-y>
  <view>
    <view> a </view>
    <view> b </view>
    <view> c </view>
  </view>
</scroll-view>

<!-- 列表项作为直接子节点,有按需绘制优化 -->
<scroll-view type="list" scroll-y>
  <view> a </view>
  <view> b </view>
  <view> c </view>
</scroll-view>

<!-- 列表项作为 list-view 直接子节点,有按需绘制优化,同上 -->
<scroll-view type="custom" scroll-y>
  <list-view>
    <view> a </view>
    <view> b </view>
    <view> c </view>
  </list-view>
</scroll-view>

自定义模式

列表滚动时常会和特殊布局能力结合使用,如滚动到顶部时自动吸顶 sticky 效果,或瀑布流布局。

Skyline 内置了这部分能力,可直接使用 sticky-headergrid-view 组件实现。

list-view 组件的效果跟列表模式是等价的,如果不需要这些特殊布局能力,可任意选择写法。

需要注意的是自定义模式下,ScrollView 直接子节点本身并没有按需绘制优化,按需绘制的能力是 list-view 组件实现的,custom 模式组合了这些能力。

<scroll-view type="custom" scroll-y>
  <sticky-section>
    <sticky-header>
      <view> h </view>
    </sticky-header>

    <!-- 非 list-view 子节点,无按需绘制优化 -->
    <view> 1</view>
    <view> 2 </view>

    <!-- 列表项作为 list-view 直接子节点,有按需绘制优化 -->
    <list-view>
      <view> a </view>
      <view> b </view>
      <view> c </view>
    </list-view>
  </sticky-section>
</scroll-view>

<scroll-view type="custom" scroll-y>
  <sticky-section>
    <sticky-header>
      <view> h </view>
    </sticky-header>

    <!-- 列表项作为 grid-view 直接子节点,有按需绘制优化 -->
    <<grid-view type="masonry">
      <view> a </view>
      <view> b </view>
      <view> c </view>
    </<grid-view>
  </sticky-section>
</scroll-view>

示例代码片段

在开发者工具中预览效果

嵌套模式

这主要是针对一类嵌套滚动场景。如下图所示,SwiperItem 内也有纵向滚动的 ScrollView 组件,当在内部 ScrollView 上滑动时,会与外层 ScrollView 产生手势冲突,导致外层的页面始终无法滚动。

  • 纵轴+横轴+纵轴的嵌套组合
  • 同一方向的滚动容器存在手势冲突
  • 可使用手势协商解决,但过程较为烦琐

为使得内外的滚动衔接更为流畅,框架新增了 <nested-scroll-headernested-scroll-body 组件结合嵌套模式使用,省去了开发者解决手势的麻烦。

<scroll-view type="nested" scroll-y>
  <nested-scroll-header>
    <view></view>
  </nested-scroll-header>
  <nested-scroll-body>
    <swiper>
      <swiper-item>
        <scroll-view 
          type="list"
          associative-container="nested-scroll-view"
        >
          <view>a</view>
          <view>b</view>
        </scroll-view>
      </swiper-item>
      <swiper-item>...</swiper-item>
      <swiper-item>...</swiper-item>
    </swiper>
  </nested-scroll-body>
</scroll-view>

示例代码片段

在开发者工具中预览效果

可拖拽容器

页面内的半屏可拖拽容器是很常见的一种交互,用户可通过滚动扩大列表范围。以往开发者可通过手势协商的能力来实现,但较为繁琐。

框架提供了 draggable-sheet 组件,封装了这一能力,包括

  • 隐藏滚动条
  • 滚动回弹效果
  • 滚动到指定位置(snap 到关键点)
  • 滚动帧回调(实现滚动驱动动画)
<draggable-sheet
  class="sheet"
  initial-child-size="0.5"
  min-child-size="0.2"
  max-child-size="0.8"
  snap="{{true}}"
  snap-sizes="{{[0.4, 0.6]}}"
  worklet:onsizeupdate="onSizeUpdate"
>
  <scroll-view
    scroll-y="{{true}}"
    type="list"
    associative-container="draggable-sheet"
    bounces="{{true}}"
  />
</draggable-sheet>