# 自定义路由

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

Skyline 渲染引擎下,页面有两种渲染模式: WebViewSkyline,它们通过页面配置中的 renderer 字段进行区分。在连续的 Skyline 页面间跳转时,可实现自定义路由效果,路由动画的曲线、时长均可交由开发者控制。

# 立即体验

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

# 使用步骤

使用自定义路由分为 3 个步骤:

  1. 定义路由动画
  2. 页面跳转时指定路由类型
  3. 绑定页面手势

# 步骤一:定义路由动画

定义路由动画的主要步骤是注册一个 builder 函数,来定义小程序页面间切换过程的动画过程。

builder 函数接受的参数均为 sharedValue 类型,当路由事件发生时,Skyline 引擎会同步修改它们的值。开发者在 builder 函数中需要实现的是 handlePrimaryAnimationhandleSecondaryAnimation 这两个动画函数,当使用的 sharedValue 变化时,对应的 handlePrimaryAnimation / handleSecondaryAnimation 函数就会被执行。


// 注册 builder 函数
wx.router.addRouteBuilder("Cupertino", CupertinoRouteBuilder)

// 实现 builder 函数
const CupertinoRouteBuilder = ({
    primaryAnimation,
    secondaryAnimation, 
    primaryAnimationStatus,
    secondaryAnimationStatus, 
    userGestureInProgress
}) => {
    // 实现 worklet 动画函数 handlePrimaryAnimation 、 handleSecondaryAnimation
    const { windowWidth } = wx.getWindowInfo();
    const handlePrimaryAnimation = () => {
        "worklet";
        // primaryAnimation 是 builder 函数的入参
        const translateX = windowWidth * (1 - primaryAnimation.value)
        return {
        transform: `translateX(${translateX}px)`
        }
    }
    const handleSecondaryAnimation = ...
    return {
        handlePrimaryAnimation,
        handleSecondaryAnimation,
        opaque: true, 
        transitionDuration: 300, 
        reverseTransitionDuration: 300, 
        barrierColor: "", 
        barrierDismissible: false, 
        barrierLabel: "", 
        canTransitionTo: true, 
        canTransitionFrom: true, 
    }
}

# builder 函数

# 入参

属性 类型 说明
primaryAnimation sharedValue 页面进入和退出时的动画进度,取值:0 - 1
secondaryAnimation sharedValue 下一个页面进入时,当前页面的动画进度,0-1
primaryAnimationStatus AnimationStatus: sharedValue primaryAnimation 的状态
AnimationStatus 值 说明
0 动画一开始就停止了
1 动画从头到尾进行中
2 动画从尾到头进行中
3 结束动画?
secondaryAnimationStatus AnimationStatus: sharedValue secondaryAnimation 的状态
userGestureInProgress sharedValue 手势进行中状态

# 出参

参数 类型 默认值 说明
handlePrimaryAnimation worklet 动画函数 返回 AnimatedStyle,控制页面的进入和退出动画
handleSecondaryAnimation worklet 动画函数 返回 AnimatedStyle,控制下一个页面进入时,当前页面的动画
opaque boolean true 页面推入后,原栈顶页面是否可见,true:不可见,false:可见
transitionDuration number 300 页面推入动画时长
reverseTransitionDuration number 300 页面退出动画时长
barrierColor string 页面推入时的遮罩层背景色,默认无
barrierDismissible boolean false 点击遮罩是否返回上一页
barrierLabel string 无障碍语义
canTransitionTo boolean true 下一个页面推入时,当前页 secondaryAnimation 是否生效
canTransitionFrom boolean true 当前页推入时,前一个页面 secondaryAnimation 是否生效

# worklet 动画函数

builder 函数中的 handlePrimaryAnimationhandleSecondaryAnimation 都是 worklet 动画函数,返回 AnimatedStyle,在路由过程中在 UI 线程执行,不会阻塞 JS 线程。

primaryAnimation 默认是线性的,为了丰富页面跳转的动画效果,worklet 支持了常见的动画缓动函数 Easing,开发者可以根据业务需求实现需要的页面切换动画效果。

  /*
   * CurveAnimation 利用 derivedValue 机制
   * 封装了进入和退出时的动画曲线
   */
  function CurveAnimation ({ animation, animationStatus, curve, reverseCurve }) {
    const { derived } = wx.worklet;
    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);
    });
  }

  /**
   * worklet 支持了常见的动画缓动函数
   * 示例使用 bezier 三次贝塞尔曲线
   */
  const Curves = {};
  if (wx.worklet) {
    const { Easing } = wx.worklet;
    Object.assign(Curves, {
      fastLinearToSlowEaseIn: Easing.cubicBezier(0.18, 1.0, 0.04, 1.0),
      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),
    });
  }

  const { windowWidth } = wx.getWindowInfo();
  const _curvePrimaryAnimation = CurveAnimation({
    animation: primaryAnimation, // builder 函数入参
    animationStatus: primaryAnimationStatus, // builder 函数入参
    curve: Curves.linearToEaseOut,
    reverseCurve: Curves.easeInToLinear,
  });


  /**
   * primaryAnimation 控制页面进入和退出动画
   * 1. 页面进入时采用 curve 曲线生成的值
   * 2. 页面退出时采用 reverseCurve 生成的值
   */
  const handlePrimaryAnimation = () => {
    "worklet";
    let t = primaryAnimation.value;
    if (!userGestureInProgress.value) {
      t = _curvePrimaryAnimation.value;
    }

    const translateX = windowWidth * (1 - t)
    return {
      transform: `translateX(${translateX}px)`
    };
  };

# 步骤二:页面跳转时指定路由类型

// 低版本基础库会降级到多 webview 下的路由动画
wx.navigateTo({
  url: 'pageB',
  routeType: 'Cupertino',
})

# 步骤三:绑定页面手势

通常我们会在页面上绑定手势,除了用户点击按钮返回外,还可以向右、向下拖动页面返回。此时就需要在页面内绑定手势进行处理。

绑定手势之后,根据 GestureState 状态值来接管页面动画,手势事件 this.customRouteContext 包含当前页面定义路由 builder 时的全部变量。

在手势事件中根据手势来修改 primaryAnimation 的值实现页面手势动画。

# GestureState 状态值

说明
0 POSSIBLE 此时手势未识别,如 panDown等
1 BEGIN 手势已识别
2 ACTIVE 连续手势活跃状态
3 END 手势终止
4 CANCELLED 手势取消

# this.customRouteContext 属性

手势接管开始必需调用 startUserGesture,手势结束必须调用 stopUserGesture,退出页面必须调用 didPop。

Skyline 引擎无法内部处理掉这些调用,原因是引擎无法得知开发者最终是否想退出这个页面。

属性 类型 说明
didPop function 退出页面时调用事件
startUserGesture function 开始页面手势事件,更新 userGesturelnProgress 值为 1
stopUserGesture function 结束页面手势事件,更新 userGesturelnProgress 值为 0
skylinePageld sharedValue
secondaryAnimation sharedValue
userGesturelnProgress sharedValue
primaryAnimationStatus sharedValue
primaryAnimation sharedValue
skylineWindowld sharedValue
secondaryAnimationStatus sharedValue
routeType string

# 示例代码

<!-- absolute 定位到页面左侧的返回条,在上面滑动时可以控制页面路由动画 -->
<horizontal-drag-gesture-handler onGestureEvent="handleHorizontalDrag">
  <view class="gesture-back-area"></view>
<horizontal-drag-gesture-handler/>
/**
 * 1. customRouteContext 中包含当前页面定义路由 builder 时的全部变量
 * 2. 可以从中获取 primaryAnimation,对其进行修改,进而驱动页面动画
 * 3. 手势返回时由开发者控制 primaryAnimation 的更新
 * 4. 接口调用页面进入和退出时,由 skyline 引擎控制 primaryAnimation 的更新
 */
Page({
  handleDragStart() {
    "worklet";
    const { startUserGesture } = this.customRouteContext;
    // 触摸开始时需要调用 startUserGesture
    // 更新 userGestureInProgress 的值
    // ⚠️⚠️ 
    startUserGesture();
  },

  handleDragUpdate(delta) {
    "worklet";
    const { primaryAnimation } = this.customRouteContext;
    const newVal = primaryAnimation.value - delta;
    // 跟随手指拖动页面效果
    primaryAnimation.value = clamp(newVal, 0.0, 1.0);
  },

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

    // 页面滑动超过一半或者手指离开时的释放速度大于指定值时
    // 判定最终要退出页面,继续滑动页面到右侧边缘
    // 否则判定取消此时退出,页面返回到左侧边缘
    let animateForward = false;
    if (Math.abs(velocity) >= _kMinFlingVelocity) {
      animateForward = velocity <= 0;
    } else {
      animateForward = primaryAnimation.value > 0.5;
    }
    const t = primaryAnimation.value;
    const animationCurve = Curves.fastLinearToSlowEaseIn;
    if (animateForward) {
      const droppedPageForwardAnimationTime = Math.min(
        Math.floor(lerp(_kMaxDroppedSwipePageForwardAnimationTime, 0, t)),
        _kMaxPageBackAnimationTime
      );

      // primaryAnimation.vaue = 1 表示未退出页面
      primaryAnimation.value = timing(
        1.0,
        {
          duration: droppedPageForwardAnimationTime,
          easing: animationCurve,
        },
        () => {
          // 最终均需调用 stopUserGesture
          // ⚠️⚠️ 
          stopUserGesture();
        }
      );
    } else {
      const droppedPageBackAnimationTime = Math.floor(
        lerp(0, _kMaxDroppedSwipePageForwardAnimationTime, t)
      );
      // primaryAnimation.vaue = 0 表示退出页面
      primaryAnimation.value = timing(
        0.0,
        {
          duration: droppedPageBackAnimationTime,
          easing: animationCurve,
        },
        () => {
          // 退出页面时需调用 didPop
          // ⚠️⚠️
          didPop();

          // 最终均需调用 stopUserGesture
          // ⚠️⚠️
          stopUserGesture();
        }
      );
    }
  },

  handleHorizontalDrag(gestureEvent) {
    "worklet";
    if (gestureEvent.state === GestureState.BEGIN) {
      this.handleDragStart();
    } else if (gestureEvent.state === GestureState.ACTIVE) {
      const delta = gestureEvent.deltaX / windowWidth;
      this.handleDragUpdate(delta);
    } else if (gestureEvent.state === GestureState.END) {
      const velocity = gestureEvent.velocityX / windowWidth;
      this.handleDragEnd(velocity);
    } else if (gestureEvent.state === GestureState.CANCELLED) {
      this.handleDragEnd(0.0);
    }
  }
})

# 原理说明

route

当前页面栈顶为 PageA,当调用 wx.navigateTo 推入 PageB 时:

  • PageA 对应的 secondaryAnimation0.0 -> 1.0PageBprimaryAnimation0.0 -> 1.0

此时页面栈顶变为 PageB,当调用 wx.navigateBack 推出 PageB时:

  • PageA 对应的 secondaryAnimation1.0 -> 0.0PageBprimaryAnimation1.0 -> 0.0

primaryAnimationsecondaryAnimation 均为 SharedValue 类型(如果你对如何使用 SharedValue 还不熟悉,可以先看看 worklet 文档),表示路由的进度。当路由事件发生时,Skyline 引擎会同步修改它们的值。

handlePrimaryAnimationhandleSecondaryAnimation 返回 AnimatedStyle,它们的作用跟 this.applyAnimaedStyle 接口是一样,只不过作用于页面的 “根” 节点。

userGestureInProgress 表示当前是否处于手势返回过程中。默认路由效果下,手势拖动页面返回时,页面动画会是一个线性的,跟随手移动的状态。而调用接口返回时,则是不同的曲线。

# 开箱即用自定义路由代码

这个包装到代码片段或者github直接供开发者使用