# 手势系统

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

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

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

支持的手势组件类型如下

手势 触发时机
<tap-gesture-handler> 点击时触发
<double-tap-gesture-handler> 双击时触发
<pan-gesture-handler> 拖动(横向/纵向)时触发
<scale-gesture-handler> 多指缩放时触发
<force-press-gesture-handler> iPhone 设备重按时触发
<vertical-drag-gesture-handler> 纵向滑动时触发
<horizontal-drag-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>

# 通用属性

属性 类型 默认值 必填 说明
onGestureEvent handler 手势事件回调
shouldResponseOnMove handler move 过程中手势是否响应
shouldAcceptGesture handler 是否识别手势
simultaneousHandlers Array<string> [] 可协同触发的手势节点
nativeView string 代理的原生节点类型

# 注意事项

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

# 使用方法

# 示例一:绑定手势

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

# 示例二:嵌套的手势绑定

<!-- 手势组件可以嵌套,但同类型事件触发时,外层的不会响应 -->
<!-- 如下示例,纵向滑动时 handleVerticalDrag 会触发,hanldePan 不触发 -->
<!-- 横向滑动时,handlePan 才会触发 -->
<pan-gesture-handler onGestureEvent="handlePan">
  <vertical-drag-gesture-handler onGestureEvent="handleVerticalDrag">
     <view></view>
  <vertical-drag-gesture-handler>
</pan-gesture-handler>

# 示例三:代理原生组件

<!-- native-view 目前仅支持 scroll-view 类型 -->
<vertical-drag-gesture-handler
  onGestureEvent="handlePan"
  native-view="scroll-view"
  shouldResponseOnMove="shouldResponse"
  shouldAcceptGesture="shouldAccept"
>
  <scroll-view>
    <view/>
    <view/>
  <scroll-view>
</vertical-drag-gesture-handler>
Page({
  // 滑动过程中触发
  // 返回 false 时 scroll-view 会停止滚动
  // 但可再次变为 true, 继续滚动
  shouldResponse(pointerEvent) {
    "worklet";
    return false;
  },

  // 滑动开始时触发,返回 false 时,当次滑动操作不响应
  // 直到手指抬起后,再次触摸屏幕后才会触发
  shouldAccept() {
    "worklet";
    return false;
  },
});

# 示例四:滚动组件新增属性

<scroll-view
  adjustDecelerationVelocity="adjustDecelation"
  bindscroll="handleScroll"
>
  <view />
  <scroll-view></scroll-view
></scroll-view>
Page({
  // 手指滑动离开滚动组件时,指定衰减速度
  // velocity 为手指离开时的速度
  // 在手势协商的示例中需要修订这个速度,保证平滑滚动
  adjustDecelation(velocity) {
    "worklet";
    return velocity;
  },

  // 声明为 worklet 函数时,在 UI 线程触发
  // 否则为普通函数,在 JS 线程触发
  // 返回参数两种方式保持一致
  handleScroll(evt) {
    "worklet";
    console.log(evt.detail.scrollTop);
  },
});

# 示例五:手势协商

我们以一个视频号评论列表的交互效果,来了解下手势协商机制的一般处理流程。

  1. 由于列表区域既要响应滚动也要响应拖动,需通过 simultaneousHandlers 声明两个手势类型可同时触发;
  2. scroll-view 是原生组件,为了能够代理其内部手势,需要声明 native-view="scroll-view"
  3. 监听 scroll-viewbindscroll 事件,记录 scrollTop

接下来,有一些边界情况需要判断

  1. 当上下滑动时,shouldScrollViewResponseshouldPanResponse 会依次触发,由开发者来控制对应的手势在当前 move 过程中是否需要响应;
  2. 若一开始向上滑动时,列表区域需要响应的时滚动手势,因此
    1. shouldScrollViewResponse 应当返回 true
    2. shouldPanResponse 应当返回 false
    3. scroll-view 内部完成滚动过程;
  3. 若一开始向下滑动时,列表区域需要响应的是拖动手势,因此
    1. shouldScrollViewResponse 应当返回 false;
    2. shouldPanResponse 应当返回 true
    3. 此时 handlePan 回调将被触发,完成拖动过程;
  4. 在拖动列表过程中,上下滑动,此时
    1. shouldScrollViewResponse 应当返回 false 阻止 scroll-view 滚动
  5. 拖动过程中松开手,列表区惯性滚动到初始位置,此时有个特殊情况需要处理
    1. 由于滚动过程中 scroll-view 的一些事件被忽略掉了,导致松手时的速度计算错误,需要通过 adjustDecelerationVelocity 进行调整,
    2. scrollTop > 0 时,表示正常滚动 scroll-view,直接返回入参中的速度即可
    3. scrollTop <=0 时,表示产生回弹效果,此时可指定松手时的速度为 0,避免抖动
    4. 通常情况下直接复用这里的判断逻辑即可,如果需要自定义 scroll-view 滚动后松手的滑动效果,可自定义返回值(加快 or 减慢)
<!-- 未添加 simultaneousHandlers 属性时,纵向滑动仅 handleScroll 会触发 -->
<!-- 声明 simultaneousHandlers 时,handleScroll 和 handlePan 可依次触发 -->
<pan-gesture-handler
  id="pan"
  simultaneousHandlers="{{['scroll']}}"
  onGestureEvent="handlePan"
>
  <view>
    <vertical-drag-gesture-handler
      id="scroll"
      native-view="scroll-view"
      simultaneousHandlers="{{['pan']}}"
      onGestureEvent="handleScroll"
    >
      <scroll-view> </scroll-view>
    </vertical-drag-gesture-handler>
  </view>
</pan-gesture-handler>
Page({
  data: {
    animation: null,
    list: new Array(40).fill(1),
  },
  onLoad() {
    if (this.renderer !== 'skyline' || !supportWorklet()) return;
    const transY = shared(0);
    this.applyAnimatedStyle('.list-wrp', () => {
      'worklet';
      return { transform: `translateY(${transY.value}px)` };
    });
    this.transY = transY;
    this.scrollTop = shared(0);
    this.startPan = shared(false);
  },
  shouldPanResponse() {
    'worklet';
    return this.startPan.value;
  },
  shouldScrollViewResponse(pointerEvent) {
    'worklet';
    if (this.transY.value > 0) return false;
    const scrollTop = this.scrollTop.value;
    const { deltaY } = pointerEvent;
    const result = !(scrollTop <= 0 && deltaY > 0);
    this.startPan.value = !result;
    return result;
  },
  handlePan(gestureEvent) {
    'worklet';
    if (gestureEvent.state === GestureState.ACTIVE) {
      const curPosition = this.transY.value;
      const destination = Math.max(0, curPosition + gestureEvent.deltaY);
      if (curPosition === destination) return;
      this.transY.value = destination;
    }

    if (gestureEvent.state === GestureState.END || gestureEvent.state === GestureState.CANCELLED) {
      this.transY.value = timing(0);
      this.startPan.value = false;
    }
  },
  adjustDecelerationVelocity(velocity) {
    'worklet';
    const scrollTop = this.scrollTop.value;
    return scrollTop <= 0 ? 0 : velocity;
  },
  handleScroll(evt) {
    'worklet';
    this.scrollTop.value = evt.detail.scrollTop;
  },
});