如何实现移动端事件引擎

3,210 阅读5分钟

[作者]:糖莹

近几年随着移动端设备的快速发展,加上多触控交互的复杂,移动端事件逐渐显现重要性。移动端事件从单个(多个)手指接触屏幕开始,其他手指也可以触碰设备,并随意滑动,当所有的手指离开屏幕时,交互才算结束。常见有以下几种:

  • pan 平移:手指触碰屏幕,移动,最后离开。
  • clicktap)点击:手指在某个位置范围内进行快速点击。
  • swipe 快扫:手指在设备上快速移动。
  • press 按压:手指按下一段时间且不移动。
  • pinch 缩放:两个(或多个)手指靠近或远离,用于放大缩小。

click or tap: 之前移动端频繁的click事件会有200ms的延迟,会用tap代替。但现在已经不存在这个问题,所以又用回了click事件(200ms的前世今生)。

今年我们将F2 、F6的移动端能力抽出沉淀于 FEngine 中,其中就包括移动端事件。成熟现有的手势库已经有许多,比如 hammer。但多数成熟的手势库是基于鼠标事件 + 触碰事件加以兼容,并且考虑情况复杂多样,导致体积太大。因此我们自己实现了基于指针事件的常用手势库。 为什么基于 Pointer event 去封装而不是 Touch event?三类事件又有什么区别?

事件:鼠标与触控

事件规范有三种,分别是 Pointer event (指针事件)、 Mouse event (鼠标事件) 和 Touch event (触摸事件)。指针事件是鼠标与触控交互发展的标准,Pointer 是输入设备层的抽象,定义了一种同时处理鼠标和触控事件的方法,开发者无需为不同响应事件编写不同函数。以下是三者对照表:

Mouse eventTouch eventPointer event
mousedowntouchstartpointerdown
mouseenterpointerenter
mouseleavepointerleave
mousemovetouchmovepointermove
mouseoutpointerout
mouseoverpointerover
mouseuptouchendpointerup

鼠标事件和指针事件是独立的触点事件,可以看出鼠标事件和指针事件可以做一个对等关系,指针事件继承并扩展了鼠标事件,在其基础上增加了一些新的属性。

但触摸事件还是有一些不同,触摸事件是一个连续的状态。当鼠标移动到一个元素上,系统是很清晰发生什么事情,或者是 move 或者是 click。但触摸事件不是,系统必须给这个事件上增加一些时间并根据后一个状态结合判断并且还可能同时发生多个触摸操作。鼠标事件只有单个。除此之外,触摸事件比鼠标事件精度要小很多。鼠标光标一般会精确到一个像素,但触摸事件会有像素的重合,允许一定的误差。总之,两者相似,但并不是完全相同。 目前各大浏览器都兼容了pointerevents 标准 ,所以我们事件系统基于 Pointer 事件去监听封装,这样代码可以灵活兼容PC端和移动端。

基于Pointer事件封装移动端事件

我们为对象添加 pointerstartpointerendpointermovepointercancel 事件,然后在监听基础事件过程中,通过“组合”等方式去模拟移动端事件。但 Pointer 事件是独立的触点事件,而移动端事件却是多触点,并且连续的过程,如何将多个触点联系起来形成一个连续的手势。

状态管理

为了支持多点触控交互,需要在各个阶段保留记录状态,直到触点全部离开屏幕,因此我们需要增加状态管理,Pointer event 中每一个 pointerId 表示不同的触点,根据 pointerId 缓存事件状态。比如 Touch event 中,就是增加了 touches、changeTouches 和 targetTouches 属性来作为多触点的管理。

related for Touch Events

  • touch:touch 对象表示在触控设备上的触摸点。包含一些系列属性pageX、pageY、clientX、clientY, 表 示用户触摸操作所作用的区域。
  • touches:一 个 TouchList 对象,包含了所有当前接触触摸平面的触点的 Touch 对象。

伪代码如下:

export class EventService extends EventEmitter { 
  // 维护一个数组存触摸点  
  private evCache: Touchlist;

  _start(e) {
    evCache = []
    evCache.push(e);
  }
  
  _move(e) {
    handlePointers(e, 'update');
  }
  
  _end(e) {
    handlePointers(e, 'delete');
  }
  
  _cancel(e) {
    evCache = []
  }
  
  handlePointers() {
    for (let i = 0; i < pointers.length; i++) {
      if (evCache[i].pointerId === e.pointerId) {
        if (type === 'update') {
          evCache[i] = e;
        } else if (type === 'delete') {
          evCache.splice(i, 1);
        }
      }
    }
  }

几类移动端事件封装

做好状态管理,接下来我们就可以封装手势了。任何手势都可以抽象成:开始 (start)、移动(move)、 结束(end)或中断(cancel)三个过程。这边从单指到多指、从时间长短举了四个 🌰 。

pan平移事件 与 press长按事件

平移与长按是最容易混淆的两类事件,主要区别是在开始到移动之间是否停顿了时间,所以在 move 中,我们才能判断 是 pan 还是 press,再触发相对应的 panstart/pressstart、panmove/pressmove 事件。如果没有进行移动或者拿起,我们就判断触发press事件。

_start = (ev: any) => {
  this.pressTimeout = setTimeout(() => {
    // 这里固定触发press事件
    const eventType = 'press';
    const direction = 'none';
    ev.direction = direction;
    this.emitStart(eventType, ev);
    this.emit(eventType, ev);
    this.eventType = eventType;
    this.direction = direction;
  }, PRESS_DELAY);
}

getEventType(point: Point) {
  // 如果有pan事件的处理,press则需要停顿250ms, 且移动距离小于10
    const now = clock.now();
    if (now - startTime > PRESS_DELAY && calcDistance(startPoints[0], point) < 10) {
      type = 'press';
    } else {
      type = 'pan';
    }
    return type;
}

swipe快扫事件:单指、时间短

swipe 快扫特点是移动速率,如果从开始到结束整个动作速率大于一定数值就为快扫。所以我们在 move 里记录最后2次 move 的时间和坐标,在 end 里判断速率是否满足条件触发。

_end = (ev) => {
  const lastMoveTime = this.lastMoveTime;
  const now = Date.now();
  
  const prevMoveTime = this.prevMoveTime || this.startTime;
  const intervalTime = lastMoveTime - prevMoveTime;
  if (intervalTime > 0) {
    const prevMovePoints = this.prevMovePoints || this.startPoints;
    const lastMovePoints = this.lastMovePoints;
    // move速率
    const velocity = calcDistance(prevMovePoints[0], lastMovePoints[0]) / intervalTime;
      if (velocity > 0.3) {
        ev.velocity = velocity;
        ev.direction = calcDirection(prevMovePoints[0], lastMovePoints[0]);
        this.emit('swipe', ev);
      }
    }
  }

pinch缩放事件:双指

当触摸点为双指时,我们需要考虑缩放事件。在 shape 触发 start 的时候判断 当前屏幕上(evCache) 是否为两指,如果为两指记录下两指的 center 及两指的距离。在 shape 触发 touchmove 的时候根据当前的两指 center 和 距离可以得到缩放的倍数和 center。

 _move = (ev) => { 
     const points = this.evCache.map(d=> {x, y});
    // 多指触控
    if (points.length > 1) {
      // touchstart的距离
      const startDistance = this.startDistance;
      const currentDistance = calcDistance(points[0], points[1]);
      ev.zoom = currentDistance / startDistance;
      ev.center = this.center;
      // 触发缩放事件
      this.emit('pinch', ev);
    } 
 }

浏览器对每个 pointer 事件都做了节流的操作,但在多指的情况下,每个触点都会触发一次事件,事件触发频率就又会多倍的增加,其实是不必要的。因此我们内部再做一个节流处理。

 // 触发事件
  private _emit(type: string, ev: GestureEvent) {
    this.pushEvent(type, ev);
    const { el, throttleTimer, emitThrottles } = this;
    if (throttleTimer) {
      return;
    }

    const global = el.ownerDocument?.defaultView;
    this.throttleTimer = global.requestAnimationFrame(() => {
      for (let i = 0, len = emitThrottles.length; i < len; i++) {
        const { type, ev } = emitThrottles[i];
        this.emit(type, ev);
      }
      // 清空
      this.throttleTimer = 0;
      this.emitThrottles.length = 0;
    });
  }
}

以上,我们的移动端事件基本完成。接下来我们考虑一些复杂边界的情况。

手势切换

手势其实是一组连续的状态,要根据时间和上下文来判断,还常常 多个事件同时发生。比如用户通过两个手指进行了图表的放大缩小 🤌 ,接着抬起一个手指,另一种手指继续进行滑动交互。因此我们记录双指的初始状态,时间,以及 processEvent 参数记录当前触发的事件 。在 endemit 某个事件时也结束掉所有进行中的事件,在 startemit 某个事件时检查是否为进行中事件。

  // 触发start事件
  emitStart(type, ev) {
    if (this.isProcess(type)) {
      return;
    }
    this.emit(`${type}start`, ev);
  }

  // 触发end事件
  emitEnd(ev) {
    const processEvent = this.processEvent;
    Object.keys(processEvent).forEach((type) => {
      this.emit(`${type}end`, ev);
      delete processEvent[type];
    });
  }

除了多种事件并发,还需要考虑手势切换。在end中需要判断当前是否为多指,若为多指,需手动触发start过程。

// 多指离开 1 指后,重新触发一次start
if (evCache.length > 0) {
  this._start();
 }

canvas 是通过 javascript 像素制的动态绘制图形,里面绘制的元素并没有事件支持。但在 @antv/g 中完成了与 DOM Event 一致的事件传播流程,在事件处理中利用拾取插件获得当前事件触发的图形,画布上的每个元素都可以添加事件,感兴趣可以看 g5.0设计文档。 最后加上我们这套基于web标准的移动端事件,就可以支撑F2和F6啦。

附录:

欢迎 Star 🌟 ~

Pointer Event 属性表

image.png