全局工具栏

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

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

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

  1. app-bar 组件仅支持 Skyline 渲染,从 webview 页跳 Skyline 页才会出现,返回到 webview 页则消失。
  2. 连续的 Skyline 页面间跳转,app-bar 组件为同一实例,中间若隔着 webview,则为不同实例,状态不同步。
  3. 从 Skyline 返回到 webview 页面,再次进入 Skyline,app-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({})
    }
  }
})

共享元素动画

原生 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.navigateTo 由 A 进入 B,称 A 为源页面(from 页),B 为目标页(to 页)
  2. pop 阶段:通过 wx.navigateBack 由 B 返回 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 位置。

默认插值方式为对 Rect 的 LTWH 分别进行线性插值。当 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 设置为如下类型时,默认的插值方式为对 Rect 的 LTWH 分别进行线性插值。

  • 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-element 在 overlay 层进行动画,从起点向终点位置过渡。

动画结束时刻

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

注意事项

  • Skyline 版本 share-element 无需结合 page-container 使用
  • 给 share-element 组件设置 padding、justify-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. 始终以 list 页 share-element 为飞跃物
  2. 通过 on-frame 改写飞跃物容器的位置大小,使其充满 overlay 层
  3. 以图片实际占据空间确定始末容器的位置大小,而不是 share-element 节点占据的空间
  4. 飞跃过程中使用缩略图,高清图下载完成后进行替换

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

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

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

示例代码片段-进阶

页面返回手势

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

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

使用方法

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

一行代码配置

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

属性 类型 默认值 说明
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 增加 routeConfig 和 routeOptions 两个属性。

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

示例代码片段

在开发者工具中预览效果

容器转场动画

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

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

效果演示

使用方法

开发者工具需要升级到 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 否 动画类型

示例代码片段

在开发者工具中预览效果

预设路由

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

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

自定义路由

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

Skyline 渲染引擎下,页面有两种渲染模式: WebView 和 Skyline,它们通过页面配置中的 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.navigateTo,B 页自底向上弹出,A 页下沉收缩
  2. 手势拖动:在 B 页上下滑动时,路由动画随之变化
  3. pop 阶段 :调用 wx.navigateBack,B 页向下关闭,A 恢复原样

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

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

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

路由控制器

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

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

push 阶段

  1. B 页对应的 primaryAnimation 从 0 -> 1 变化,做进入动画
  2. A 页对应的 secondaryAnimation 从 0 -> 1 变化,做压入动画

pop 阶段

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

其中,A 页 secondaryAnimation 的值始终与 B 页 primaryAnimation 的值同步变化。

通常页面的进入和退出可能采用不同的动画曲线,可通过对应的状态变量 primaryAnimationStatus 和 secondaryAnimationStatus 来区分当前处于哪一阶段,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 上常见的右滑返回。自定义路由模式下,开发者可根据不同的页面转场效果,来选取所需的退出方式,如半屏效果可采用下滑返回。关于手势监听的内容,可参考 手势系统 一章,路由手势仅是在其基础上,补充了几个路由相关的接口。

startUserGesture 和 stopUserGesture 两个函数总是成对调用的,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 控制,这样就需要在定义 A 的 CustomRouteBuilder 时考虑所有的路由类型,实现较为繁琐。

基础库 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 时, A 页 secondaryAnimation 的值始终与 B 页 primaryAnimation 的值同步变化。

我们可以在定义 B 的 CustomRouteBulder 时,通过 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 值,采用不同的动画曲线,同时设置 opaque 为 false,使得路由动画完成后仍显示 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 页作 scale 和 translate 变换实现下沉效果。A 页 secondaryAnimation 的值始终与 B 页 primaryAnimation 的值保持同步。

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

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 值手动修改,自由掌控其过渡方式时,才需要调用 startUserGesture 和 stopUserGesture 接口。

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

最后的实现效果如图

设置页面透明

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

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

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 接口或页面配置修改,默认为白色

示例代码

在开发者工具中预览效果

手势系统

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

Skyline 中 wxs 代码运行在 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-view 和 swiper。滚动容器纵向滚动时,使用 <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 跟踪的手指数
  • 多指滑动时,focalX 和 focalY 为多个触摸点中心焦点的坐标
  • 单指滑动时,pointerCount = 1,此时效果同 pan-gesture-handler,scale 手势是 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. 离散手势:tap 和 double-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 渲染模式下才能使用
  • 手势组件为虚组件,不会进行布局,手势组件上设置 style、class 是无效的
  • 手势组件仅能含有一个直接子节点,否则不生效
  • 手势组件的父组件样式会直接影响其子节点
  • 手势组件的回调函数均需声明为 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>

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

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 变量,尽管我们修改了 obj 的 name 属性,但在 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
    })
  }
})

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

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

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

相关接口

  • 基础类型 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
    })
  },
})

Skyline WXSS 样式支持与差异

模块支持

模块 支持情况 备注
CSS Animation ✓ 安卓 8.0.37,iOS 8.0.39,支持情况见下表
背景与边框 ✓ 常用的基本支持,详见属性支持
盒子模型 ✓ 支持 border-box 和 content-box,没有 BFC
Inline 布局 × 开发中
Inline-Block 布局 × 仅支持在 text 组件里的嵌套结构使用,完整版本开发中
Block 布局 ✓ 详见开启默认 Block 布局
Flex 布局 ✓ 包括 inline-flex 布局
字体 ✓ 基本支持,也支持自定义字体
Positioned 布局 ✓ 支持情况见下表。sticky 可使用 sticky-header/sticky-section 替代
CSS Transition ✓
CSS Variable(CSS 变量) ✓ 安卓 8.0.35,iOS 8.0.38
Media queries ✓ 只支持 DarkMode
Font-face ✓ 只支持 ttf 格式

选择器支持

类别 示例 支持度 备注
通配选择器 * {} ×
元素选择器 tag {} ✓
类选择器 .class {} ✓
ID 选择器 #id {} ✓
分组选择器 a, b {} ✓
直接子代选择器 a > b {} ✓
后代选择器 a b {} ✓
属性选择器 [attr] {} ×
一般兄弟选择器 a ~ b {} ✓ 8.0.49
紧邻兄弟选择器 a + b {} ✓ 8.0.49
伪类选择器 :active {} ✓ 支持 :first-child / :last-child;微信 8.0.49 起(对应 Skyline 1.3.0)支持 :not / :only-child / :empty;微信 8.0.50 起(对应 Skyline 1.3.3)支持 :nth-child
伪元素选择器 ::before {} ✓ 只支持 ::before 和 ::after

属性支持

样式属性 支持格式 默认值 备注
display none / flex / block flex 默认值可通过配置改成 block
position relative / absolute / fixed relative fixed 在微信客户端 8.0.43 版本开始支持,只支持相对于窗口 viewport 定位,不支持 top / left / bottom / right 默认值 auto 解析,z-index 只作用在兄弟节点;sticky 可使用 sticky-header/sticky-section 替代
overflow hidden / visible visible scroll 不支持,只能通过 scroll-view 实现;不支持单独设置 overflow-x/y
pointer-events auto / none auto
box-sizing border-box / content-box border-box
transform none / <transform-function> none
transform-origin left / center / right / top / bottom / {1, 2} 50% 50%
z-index <float> 0 不支持层叠上下文,只对兄弟节点生效;不支持在 scroll-view 下的直接子节点上应用
visibility visible / hidden visible
color black
opacity <float> 1
align-items stretch / center / flex-start / flex-end / baseline stretch
align-self auto / stretch / center / flex-start / flex-end / baseline auto
align-content stretch / center / flex-start / flex-end / space-between / space-around auto
justify-content center / flex-start / flex-end / space-between / space-around / space-evenly flex-start
flex-direction row / row-reverse / column / column-reverse column
flex-wrap nowrap / wrap / wrap-reverse nowrap
flex-grow <float> 0
flex-shrink <float> 1
flex-basis auto
order <float> 0
gap <length> 0
flex 简写属性,支持解析但以展开属性为准
background-color transparent
background-image none / none 不支持多张图片
background-size contain / cover / [ | auto]{1, 2} auto
background-position left / center / right / top / bottom / 0 0 完全支持 <bg-position>#,请参考 MDN
background-repeat repeat-x / repeat-y / repeat / no-repeat repeat
background 简写属性,支持解析但以展开属性为准
width auto
height auto
min-width auto
min-height none
max-width auto
max-height none
left auto
right auto
top auto
bottom auto
padding {1,4} 0 0 0 0
padding-left 0
padding-top 0
padding-right 0
padding-bottom 0
margin {1,4} 0 0 0 0
margin-left 0
margin-top 0
margin-right 0
margin-bottom 0
border-left-width 3
border-left-style none
border-left-color black 默认值与网页不同,网页默认值是 currentcolor
border-top-width 3
border-top-style none
border-top-color black 默认值与网页不同,网页默认值是 currentcolor
border-right-width 3
border-right-style none
border-right-color black 默认值与网页不同,网页默认值是 currentcolor
border-bottom-width 3
border-bottom-style none
border-bottom-color black 默认值与网页不同,网页默认值是 currentcolor
border-width 简写属性,支持解析但以展开属性为准
border-style 简写属性,支持解析但以展开属性为准
border-color 简写属性,支持解析但以展开属性为准
border-left 简写属性,支持解析但以展开属性为准
border-right 简写属性,支持解析但以展开属性为准
border-top 简写属性,支持解析但以展开属性为准
border-bottom 简写属性,支持解析但以展开属性为准
border 简写属性,支持解析但以展开属性为准
box-shadow none / inset? && {2,4} && ? none 不支持多个叠加
border-top-left-radius {1, 2} 0 当 border-radius 不为 0 时,四边的 border-width 可以不一样,但四边的 border-color 和 border-style 必须一致
border-top-right-radius {1, 2} 0 当 border-radius 不为 0 时,四边的 border-width 可以不一样,但四边的 border-color 和 border-style 必须一致
border-bottom-left-radius {1, 2} 0 当 border-radius 不为 0 时,四边的 border-width 可以不一样,但四边的 border-color 和 border-style 必须一致
border-bottom-right-radius {1, 2} 0 当 border-radius 不为 0 时,四边的 border-width 可以不一样,但四边的 border-color 和 border-style 必须一致
border-radius 简写属性,支持解析但以展开属性为准
transition-property none / all / transform / opacity 等 all 基本都支持,暂不一一列举
transition-duration <time> 0
transition-timing-function
transition-delay <time> 0
transition 简写属性,支持解析但以展开属性为准
font 简写属性,支持解析但以展开属性为准;不支持 caption / icon 等系统字体;
font-size 16px 不支持百分比;不支持 keyword(如 smaller 等)
line-height normal / <number> / / <percent> normal
text-align left / center / right / justify / start / end start
font-weight normal / bold / <float> normal
white-space normal / nowrap / normal
text-overflow clip / ellipsis clip 仅作用于文本节点
word-break normal / break-all normal
word-spacing normal / normal
letter-spacing normal / normal
font-family serif / sans-serif / monospace / cursive / fantasy / <string>
font-style normal / italic normal
text-decoration-line none / underline / overline / line-through none 仅作用于文本节点
text-decoration-style solid / double / dotted / dashed / wavy solid 仅作用于文本节点
text-decoration-color black 仅作用于文本节点;默认值和网页不同,网页默认值是 currentcolor
text-decoration 简写属性,支持解析但以展开属性为准;当前仅支持设置一种类型;暂不支持复合使用 text-decoration
text-shadow none / ? && {2,3} none
backdrop-filter none / [] none 不支持多个函数;不支持 drop-shadow;不支持 url;与 opacity 混合时有问题;blur 在某些情况下表现不一致;
filter none / [] none 不支持多个函数;不支持 drop-shadow;不支持 url;
mask-image none / none 不支持多张图片
animation-delay <time> 0
animation-direction normal / reverse / alternate / alternate-reverse normal
animation-duration <time> 0
animation-fill-mode forwards / both none none 和 backwards 暂时不支持,表现都等同于 forwards
animation-iteration-count infinite / <number> 1
animation-name none / <custom-ident> none
animation-timing-function
animation 简写属性,支持解析但以展开属性为准
will-change auto / contents auto 声明绘制边界,优化渲染性能

类型支持列表

类别 格式 支持度 备注
auto ✓
px ✓
rem ✓
em ×
rpx ✓
vw ✓
vh ✓
vmin ✓
vmax ✓
ratio ✓
env() ✓ 只支持 safe-area-inset-* 系列
calc() ✓
color keywords ✓
transparent ✓
currentColor × 考虑支持
rgb[a] ✓
#RRGGBB / #RGB ✓
hsl ✓
hsla ✓
url() ✓
linear-gradient() ✓
radial-gradient() ✓
conic-gradient() ✓
✓
✓
none ✓
hidden ✓
solid ✓
dashed ✓
dotted ✓
brightness() ✓ 多个函数暂时不支持
contrast() ✓
saturate() ✓
huerotate() ✓
invert() ✓
opacity() ✓
grayscale() ✓
specia() ✓
drop-shadow ×
deg ✓
grad ✓
rad ✓
turn ✓
ease ✓
ease-in ✓
ease-out ✓
ease-in-out ✓
linear ✓
cubic-bezier ✓
steps ✓
step-start ✓
step-end ✓

开启默认Block布局

在微信小程序的 Skyline 渲染模式下,节点默认使用 flex 布局。你可以通过以下配置,将其切换为默认的 block 布局。

平台 最低版本
Android 8.0.34
IOS 8.0.36
开发者工具 Nightly Build (1.06.2304262)
基础库 2.31.1

在 app.json 或 page.json 中配置:

rendererOptions: {
  "skyline": {
    "defaultDisplayBlock": true,
  }
}

开启默认ContentBox盒模型

在微信小程序的 Skyline 渲染模式下,节点默认使用 border-box 盒模型。你可以通过以下配置,将其切换为默认的 content-box 盒模型。

平台 最低版本
Android 8.0.42
IOS 8.0.42
开发者工具 Nightly Build (1.06.2310092)
基础库 3.1.0

在 app.json 或 page.json 中配置:

rendererOptions: {
  "skyline": {
    "defaultContentBox": true,
  }
}

开启tag选择器全局匹配

在微信小程序的 Skyline 渲染模式下,tag 选择器会遵循样式隔离机制,而 WebView 模式下则不受此约束。你可以通过配置 tagNameStyleIsolation: legacy 来对齐 WebView 的表现。如果指定 tagNameStyleIsolation: isolated,则会遵循样式隔离机制。

平台 最低版本
Android 8.0.51
IOS 8.0.51
开发者工具 Nightly Build (1.06.2409032)
基础库 3.6.0

在 app.json 或 page.json 中配置:

rendererOptions: {
  "skyline": {
    "tagNameStyleIsolation": "legacy",
  }
}

开启scroll-view自动撑开

在微信小程序的 Skyline 渲染模式下,scroll-view 默认需要指定宽高才能撑开。你可以通过以下配置,将其切换为根据内容自动撑开。

平台 最低版本
Android 8.0.54
IOS 8.0.54
基础库 3.7.2

在 app.json 或 page.json 中配置:

rendererOptions: {
  "skyline": {
    "enableScrollViewAutoSize": true,
  }
}

开启keyframe样式全局共享

在微信小程序的 Skyline 渲染模式下,@keyframe 规则会遵循样式隔离机制,而 WebView 模式下则不受此约束。你可以通过配置 tagNameStyleIsolation: legacy 来对齐 WebView 的表现。如果指定 tagNameStyleIsolation: isolated,则会遵循样式隔离机制。

平台 最低版本
Android 8.0.57
IOS 8.0.57
基础库 3.8.0

在 app.json 或 page.json 中配置:

rendererOptions: {
  "skyline": {
    "keyframeStyleIsolation": "legacy",
  }
}

微信小程序 Skyline 基础组件支持与差异

通用特性

特性 支持情况
无障碍访问 暂时只支持 aria-role、label、hidden、disabled
深色模式 支持
原生组件 都支持同层渲染
WeUI v2 支持

组件支持情况

总体来说,常用的组件基本都已经支持,已经标记为废弃的特性在 Skyline 下不会考虑支持。下面列出基础组件的整体情况,具体细节可以跳转到对应组件文档查看。

组件
支持情况 组件差异与备注
text 基本支持 内联文本只能用 text 组件;可以通过 span 组件与 text 或 image 内联;
view / cover-view 完全支持 涉及文本节点请参考 text 组件
image / cover-image 基本支持 SVG 支持度已经完善;部分不常用的 mode 未支持
button 完全支持
scroll-view 完全支持 需要显式指定 type="list";部分属性不需要对齐;额外支持大量新特性
swiper / swiper-item 完全支持 增强了大量特性
input / textarea 完全支持 光标选区、菜单略有不同
navigator 完全支持 只能嵌套 text 组件或文本节点;可以通过 span 组件与 text 或 image 内联
map 完全支持 开发者工具暂时不支持调试,请使用真机预览
canvas 完全支持 开发者工具暂时不支持调试,请使用真机预览
radio / radio-group 完全支持
label 完全支持
video 基本支持 全屏在 3.3.0 版本已经支持,投屏暂时不支持,开发者工具暂时不支持调试,请使用真机预览
checkbox / checkbox-group 完全支持
picker 完全支持
camera 完全支持 开发者工具暂时不支持调试,请使用真机预览
root-portal 完全支持
form 完全支持
ad 完全支持
official-account 完全支持
functional-page-navigator 支持中
live-player / live-pusher 完全支持
picker-view 基本支持 indicator-class 和 mask-style 属性暂时不支持
voip-room 完全支持
rich-text 完全支持 渲染结果可能略有不同,涉及到样式支持度;当 mode=web 时则完全与 webview 对齐
match-media 待考虑
keyboard-accessary 待考虑 可以通过 input 的 worklet:onkeyboardheightchange 回调实现
page-meta 基本支持 与全局滚动相关的属性不支持
editor 暂不考虑
web-view 暂不考虑 建议承载 web-view 的页面单独配置 "renderer": "webview"
movable-area / movable-view 暂不考虑 可以用手势配合 worklet 动画方案替代
page-container 基本支持
share-element 完全支持 与 WebView 使用方式不同,特性有所增强
icon 完全支持
progress 暂不考虑
slider 完全支持
switch 完全支持
xr-frame 暂未支持
navigation-bar 不考虑 Skyline 只能用自定义导航
open-data 完全支持 已废弃的特性不支持

Skyline 新增组件

组件
组件说明
span 用于支持内联文本和 image、navigator 的混排
snapshot 截图组件
sticky-header、sticky-section 吸顶布局容器
nested-scroll-header、nested-scroll-body 嵌套 scroll-view 场景中使用的节点,仅支持作为 <scroll-view type="nested"> 模式的直接子节点
list-view 列表布局容器,仅支持作为 <scroll-view type="custom"> 模式的直接子节点或 sticky-section 组件的直接子节点
grid-view Skyline 下的网格布局容器和瀑布流布局容器
draggable-sheet 半屏可拖拽组件
double-tap-gesture-handler 双击时触发手势
force-press-gesture-handler iPhone 设备重按时触发手势
horizontal-drag-gesture-handler 横向滑动时触发手势
long-press-gesture-handler 长按时触发手势
pan-gesture-handler 拖动(横向或纵向)时触发手势
scale-gesture-handler 多指缩放时触发手势
tap-gesture-handler 点击时触发手势
vertical-drag-gesture-handler 纵向滑动时触发手势