# 自定义路由

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

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

# 效果展示

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

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

# 使用方法

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

# 接口定义

自定义路由相关的接口

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 添加过渡动画,使其变化到 0,最后调用 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 接口或页面配置修改,默认为白色

# 示例代码

在开发者工具中预览效果