[作者]:糖莹
近几年随着移动端设备的快速发展,加上多触控交互的复杂,移动端事件逐渐显现重要性。移动端事件从单个(多个)手指接触屏幕开始,其他手指也可以触碰设备,并随意滑动,当所有的手指离开屏幕时,交互才算结束
。常见有以下几种:
pan
平移:手指触碰屏幕,移动,最后离开。click
(tap
)点击:手指在某个位置范围内进行快速点击。swipe
快扫:手指在设备上快速移动。press
按压:手指按下一段时间且不移动。pinch
缩放:两个(或多个)手指靠近或远离,用于放大缩小。
click
ortap
: 之前移动端频繁的click事件会有200ms的延迟,会用tap代替。但现在已经不存在这个问题,所以又用回了click事件(200ms的前世今生)。
今年我们将F2 、F6的移动端能力抽出沉淀于 FEngine
中,其中就包括移动端事件。成熟现有的手势库已经有许多,比如 hammer。但多数成熟的手势库是基于鼠标事件 + 触碰事件加以兼容,并且考虑情况复杂多样,导致体积太大。因此我们自己实现了基于指针事件的常用手势库。 为什么基于 Pointer event
去封装而不是 Touch event
?三类事件又有什么区别?
事件:鼠标与触控
事件规范有三种,分别是 Pointer event
(指针事件)、 Mouse event
(鼠标事件) 和 Touch event
(触摸事件)。指针事件是鼠标与触控交互发展的标准,Pointer 是输入设备层的抽象,定义了一种同时处理鼠标和触控事件的方法,开发者无需为不同响应事件编写不同函数。以下是三者对照表:
Mouse event | Touch event | Pointer event |
---|---|---|
mousedown | touchstart | pointerdown |
mouseenter | pointerenter | |
mouseleave | pointerleave | |
mousemove | touchmove | pointermove |
mouseout | pointerout | |
mouseover | pointerover | |
mouseup | touchend | pointerup |
鼠标事件和指针事件是独立的触点事件,可以看出鼠标事件和指针事件可以做一个对等关系,指针事件继承并扩展了鼠标事件,在其基础上增加了一些新的属性。
但触摸事件还是有一些不同,触摸事件是一个连续的状态。当鼠标移动到一个元素上,系统是很清晰发生什么事情,或者是 move 或者是 click。但触摸事件不是,系统必须给这个事件上增加一些时间并根据后一个状态结合判断。并且还可能同时发生多个触摸操作。鼠标事件只有单个。除此之外,触摸事件比鼠标事件精度要小很多。鼠标光标一般会精确到一个像素,但触摸事件会有像素的重合,允许一定的误差。总之,两者相似,但并不是完全相同。 目前各大浏览器都兼容了pointerevents 标准 ,所以我们事件系统基于 Pointer 事件去监听封装,这样代码可以灵活兼容PC端和移动端。
基于Pointer事件封装移动端事件
我们为对象添加 pointerstart
、pointerend
、pointermove
、pointercancel
事件,然后在监听基础事件过程中,通过“组合”等方式去模拟移动端事件。但 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 🌟 ~
- FEnigne代码仓库:github.com/antvis/FEng…
- F2代码仓库:github.com/antvis/F2
- F6代码仓库:github.com/antvis/F6