移动端已经为我们提供了touchstart,touchmove,touchcanceltouchend四个原生触摸事件。但一般情况下很少直接用到这几个事件,诸如长按事件等都需要自己去实现。不少开源的项目也实现了这些功能,如zepto的Touch模块以及hammer.js。本文将一步讲解常见移动端事件和手势的实现思路和实现方法,封装一个简单的移动端手势库。实现后的几个例子效果如下:

聊天列表实例

综合实例

如果你想看缩放和旋转效果可以点击上面链接或通过手机扫描二维码查看效果

常见的事件和手势

tap: 单击事件,类似click事件和原生的touchstart事件,或者触发时间上介于这两个事件之间的事件。

longtap: 长按事件,手指按下停留一段时间后触发,常见的如长按图片保存。

dbtap: 双击事件,手指快速点击两次,常见的如双击图片方法缩小。

move/drag: 滑动/拖动手势,指手指按下后并移动手指不抬起,类似原生的touchmove事件,常见如移动iphone手机的AssistiveTouch。

swipe(Right/Left/Up/Down):也是滑动手势,与move不同的是事件触发于move后手指抬起后并满足一定大小的移动距离。按照方向不同可划分为swipeLeft,swipeRight,swipeUpswipeDown

pinch/zoom:手指捏合,缩放手势,指两个手指做捏合和放大的手势,常见于放大和缩小图片。

rotate: 旋转手势,指两个手指做旋转动作势,一般用于图片的旋转操作。

需求

知道以上的常见事件和手势后,我们最后实现的手势库需要满足以下需求

  • 实现上述所有的事件和手势
  • 保留原生的四个基本的事件的回调
  • 支持链式调用
  • 同一个事件和手势支持多个处理回调
  • 支持事件委托
  • 不依赖第三方库

实现思路和代码

1. 基本的代码结构

库的名称这里命名为Gesture,在windows暴露的名称为GT。以下为基本的代码结构

;(function(){function Gesture(target){//初始化代码}Gesture.prototype = {//实现各种手势的代码}Gesture.prototype.constructor = Gesture;if (typeof module !== 'undefined' && typeof exports === 'object') {module.exports = Gesture;} else if (typeof define === 'function' && define.amd) {define(function() { return Gesture; });} else {window.GT = Gesture;}
})()复制代码

其中,target为实例化时绑定的目标元素,支持传入字符串和HTML元素

2. 构造函数的实现

构造函数需要处理的事情包括: 获取目标元素,初始化配置和其他需要使用到参数,以及基本事件的绑定,这里除了需要注意一下this对象的指向外,其他都比较简单,基本代码如下:

  function Gesture(target) {this.target = target instanceof HTMLElement ? target : typeof target === "string" ? document.querySelector(target) : null; //获取目标元素if(!this.target) return ; //获取不到则不实例化//这里要实例化一些参数,后面需要用到哪些参数代码都往这里放//...//绑定基本事件,需要注意this的指向,事件的处理方法均在prototype实现this.target.addEventListener('touchstart',this._touch.bind(this),false);this.target.addEventListener('touchmove',this._move.bind(this),false);this.target.addEventListener('touchend',this._end.bind(this),false);this.target.addEventListener('touchcancel',this._cancel.bind(this),false);}复制代码

下面的内容重点放在prototype的实现,分别实现_touch,_move,_end_cancel

3. 单手指事件和手势

单手指事件和手势包括:tap,dbtap,longtap,slide/move/dragswipe

  • 思路

当手指开始触摸时,触发原生的touchstart事件,获取手指相关的参数,基于需求,此时应该执行原生的touchstart回调,这是第一步;接着应该发生以下几种情况:

(1) 手指没有离开并没有移动(或者移动极小的一段距离)持续一段时间后(这里设置为800ms),应该触发longtap事件;

(2) 手指没有离开并且做不定时的移动操作,此时应该先触发原生的touchmove事件的回调,接着触发自定义的滑动事件(这里命名为slide),与此同时,应该取消longtap事件的触发;

(3) 手指离开了屏幕,开始应该触发原生的touchend事件回调,同时取消longtap事件触发,在一定时间内(这里设置300ms)离开后手指的距离变化在一定范围外(这里设置为30px),则触发swipe手势的回调,否则,如果手指没有再次放下,则应该触发tap事件,若手指再次放下并抬起,则应该触发dbtap事件,同时应该取消tap事件的触发

  • 代码实现

首先往构造函数添加以下参数:


this.touch = {};//记录刚触摸的手指
this.movetouch = {};//记录移动过程中变化的手指参数
this.pretouch = {};//由于会涉及到双击,需要一个记录上一次触摸的对象
this.longTapTimeout = null;//用于触发长按的定时器
this.tapTimeout = null;//用于触发点击的定时器
this.doubleTap = false;//用于记录是否执行双击的定时器
this.handles = {};//用于存放回调函数的对象复制代码

以下为实现上面思路的代码和说明:


_touch: function(e){this.params.event = e;//记录触摸时的事件对象,params为回调时的传参this.e = e.target; //触摸的具体元素var point = e.touches ? e.touches[0] : e;//获得触摸参数var now = Date.now(); //当前的时间//记录手指位置等参数this.touch.startX = point.pageX; this.touch.startY = point.pageY;this.touch.startTime = now;//由于会有多次触摸的情况,单击事件和双击针对单次触摸,故先清空定时器this.longTapTimeout && clearTimeout(this.longTapTimeout);this.tapTimeout && clearTimeout(this.tapTimeout);this.doubleTap = false;this._emit('touch'); //执行原生的touchstart回调,_emit为执行的方法,后面定义if(e.touches.length > 1) {//这里为处理多个手指触摸的情况} else {var self= this;this.longTapTimeout = setTimeout(function(){//手指触摸后立即开启长按定时器,800ms后执行self._emit('longtap');//执行长按回调self.doubleTap = false;e.preventDefault();},800);//按照上面分析的思路计算当前是否处于双击状态,ABS为全局定义的变量 var ABS = Math.abs;this.doubleTap = this.pretouch.time && now - this.pretouch.time < 300 && ABS(this.touch.startX -this.pretouch.startX) < 30  && ABS(this.touch.startY - this.pretouch.startY) < 30 && ABS(this.touch.startTime - this.pretouch.time) < 300; this.pretouch = {//更新上一个触摸的信息为当前,供下一次触摸使用startX : this.touch.startX,startY : this.touch.startY,time: this.touch.startTime};}},_move: function(e){var point = e.touches ? e.touches[0] :e;this._emit('move');//原生的touchmove事件回调if(e.touches.length > 1) {//multi touch//多个手指触摸的情况} else {var diffX = point.pageX - this.touch.startX,diffY = point.pageY - this.touch.startY;//与手指刚触摸时的相对坐标this.params.diffY = diffY;this.params.diffX = diffX; if(this.movetouch.x) {//记录移动过程中与上一次移动的相对坐标this.params.deltaX = point.pageX - this.movetouch.x;this.params.deltaY = point.pageY - this.movetouch.y;} else {this.params.deltaX = this.params.deltaY = 0;}if(ABS(diffX) > 30 || ABS(diffY) > 30) {//当手指划过的距离超过了30,所有单手指非滑动事件取消this.longTapTimeout &&  clearTimeout(this.longTapTimeout);this.tapTimeout && clearTimeout(this.tapTimeout);this.doubleTap = false;}this._emit('slide'); //执行自定义的move回调//更新移动中的手指参数this.movetouch.x = point.pageX;this.movetouch.y = point.pageY;}},_end: function(e) {this.longTapTimeout && clearTimeout(this.longTapTimeout); //手指离开了,就要取消长按事件var timestamp = Date.now();var deltaX = ~~((this.movetouch.x || 0)- this.touch.startX),deltaY = ~~((this.movetouch.y || 0) - this.touch.startY);var direction = '';if(this.movetouch.x && (ABS(deltaX) > 30 || this.movetouch.y !== null && ABS(deltaY) > 30)) {//swipe手势if(ABS(deltaX) < ABS(deltaY)) {if(deltaY < 0){//上划this._emit('swipeUp')this.params.direction = 'up';} else { //下划this._emit('swipeDown');this.params.direction = 'down';}} else {if(deltaX < 0){ //左划this._emit('swipeLeft');this.params.direction = 'left';} else { // 右划this._emit('swipeRight');this.params.direction = 'right';}}this._emit('swipe'); //划} else {self = this;if(!this.doubleTap && timestamp - this.touch.startTime < 300) {//单次点击300ms内离开,触发点击事件this.tapTimeout = setTimeout(function(){self._emit('tap');self._emit('finish');//事件处理完的回调},300)} else if(this.doubleTap){//300ms内再次点击且离开,则触发双击事件,不触发单击事件this._emit('dbtap');this.tapTimeout && clearTimeout(this.tapTimeout);this._emit('finish');} else {this._emit('finish');}}this._emit('end'); //原生的touchend事件},复制代码
  • 事件的绑定和执行

上面在构造函数中定义了参数 handles = {}用于存储事件的回调处理函数,在原型上定义了_emit方法用于执行回调。由于回调函数为使用时传入,故需要暴露一个on方法。以下为最初的需求:

  • 同一个手势和事件支持传入多个处理函数
  • 支持链式调用

因此,on_emit定义如下:

_emit: function(type){!this.handles[type] && (this.handles[type] = []);for(var i = 0,len = this.handles[type].length; i < len; i++) {typeof this.handles[type][i] === 'function' && this.handles[type][i](this.params);}return true;},
on: function(type,callback) {!this.handles[type] && (this.handles[type] = []);this.handles[type].push(callback);return this; //实现链式调用
},复制代码

到此为止,除了一些小细节外,对于单手指事件基本处理完成。使用类似以下代码实例化即可:


new GT('#target').on('tap',function(){console.log('你进行了单击操作');
}).on('longtap',function(){console.log('长按操作');
}).on('tap',function(params){console.log('第二个tap处理');console.log(params);
})复制代码

4. 多手指手势

常见的多手指手势为缩放手势pinch和旋转手势rotate

  • 思路

当多个手指触摸时,获取其中两个手指的信息,计算初始的距离等信息,在移动和抬起的时候再计算新的参数,通过前后的参数来计算放大或缩小的倍数以及旋转的角度。在这里,涉及到的数学知识比较多,具体的数学知识可以搜索了解之(传送门)。主要为:

(1)计算两点之间的距离(向量的模)

(2)计算两个向量的夹角(向量的內积及其几何定义、代数定义)

(3)计算两个向量夹角的方向(向量的外积)

几何定义:

代数定义:

其中

代入有,

在二维里,z₁z₂为0,得

  • 几个算法的代码实现

//向量的模
var calcLen = function(v) {//公式return  Math.sqrt(v.x * v.x + v.y * v.y);
}//两个向量的角度(含方向)
var calcAngle = function(a,b){var l = calcLen(a) * calcLen(b),cosValue,angle;if(l) {cosValue = (a.x * b.x + a.y * b.y)/l;//得到两个向量的夹角的余弦值angle = Math.acos(Math.min(cosValue,1))//得到两个向量的夹角angle = a.x * b.y - b.x * a.y > 0 ? -angle : angle; //得到夹角的方向(顺时针逆时针)return angle * 180 / Math.PI;}return 0;
}复制代码
  • 代码实现多手指手势
    _touch: function(e){//...if(e.touches.length > 1) {var point2 = e.touches[1];//获取第二个手指信息this.preVector = {x: point2.pageX - this.touch.startX,y: point2.pageY - this.touch.startY};//计算触摸时的向量坐标this.startDistance = calcLen(this.preVector);//计算向量的模} else {//...}},_move: function(e){var point = e.touches ? e.touches[0] :e;this._emit('move');if(e.touches.length > 1) {var point2 = e.touches[1];var v = {x:point2.pageX - point.pageX,y:point2.pageY - point.pageY};//得到滑动过程中当前的向量if(this.preVector.x !== null){if(this.startDistance) {this.params.zoom = calcLen(v) / this.startDistance;//利用前后的向量模比计算放大或缩小的倍数this._emit('pinch');//执行pinch手势}this.params.angle = calcAngle(v,this.preVector);//计算角度this._emit('rotate');//执行旋转手势}//更新最后上一个向量为当前向量this.preVector.x = v.x;this.preVector.y = v.y;} else {//...}},_end: function(e) {//...this.preVector = {x:0,y:0};//重置上一个向量的坐标}
复制代码

理清了思路后,多手指触摸的手势实现还是比较简单的。到这里,整个手势库最核心的东西基本都实现完了。根据需求,遗留的一点是支持事件委托,这个主要是在_emit方法和构造函数稍作修改。

//增加selector选择器
function Gesture(target,selector) {this.target = target instanceof HTMLElement ? target : typeof target === "string" ? document.querySelector(target) : null;if(!this.target) return ;this.selector = selector;//存储选择器//...
}
var isTarget = function (obj,selector){while (obj != undefined && obj != null && obj.tagName.toUpperCase() != 'BODY'){if (obj.matches(selector)){return true;}obj = obj.parentNode;
}
return false;}
Gesture.prototype. _emit =  function(type){!this.handles[type] && (this.handles[type] = []);//只有在触发事件的元素为目标元素时才执行if(isTarget(this.e,this.selector) || !this.selector) {for(var i = 0,len = this.handles[type].length; i < len; i++) {typeof this.handles[type][i] === 'function' && this.handles[type][i](this.params);}}return true;
}复制代码

5. 完善细节

  • touchcancel回调

关于touchcancel,目前代码如下:

_cancel: function(e){this._emit('cancel');this._end();
},复制代码

自己也不是很确定,在cancel的时候执行end回调合不合适,或者是否有其他的处理方式,望知晓的同学给予建议。

  • touchend后的重置

正常情况下,在touchend事件回调执行完毕后应该重置实例的的各个参数,包括params,触摸信息等,故将部分参数的设置写入_init函数,并将构造函数对应的部分替换为this._init()

_init: function() {this.touch = {};this.movetouch = {}this.params = {zoom: 1,deltaX: 0,deltaY: 0,diffX: 0,diffY:0,angle: 0,direction: ''};
}
_end: function(e) {//...this._emit('end');this._init();
}
复制代码
  • 增加其他事件

在查找资料的过程中,看到了另外一个手势库AlloyFinger,是腾讯出品。人家的库是经过了大量的实践的,因此查看了下源码做了下对比,发现实现的思路大同小异,但其除了支持本文实现的手势外还额外提供了其他的手势,对比了下主要有以下不同:

  • 事件的回调可以通过实例化时参数传入,也可以用on方法后续绑定
  • 提供了卸载对应回调的off方法和销毁对象的方法destroy
  • 不支持链式调用
  • 不支持事件委托
  • 手势变化的各种参数通过扩展在原生的event对象上,可操作性比较高(但这似乎有好有坏?)
  • 移动手指时计算了deltaXdeltaY,但没有本文的diffXdiffY,可能是实际上这两参数用处不大
  • tap事件细分到tapsingletapdoubletap和longtap,长按后还会触发singletap事件,swipe没有细分,但提供方向参数
  • 原生事件增加了多手指触摸回调twoFingerPressMove,multipointStart,multipointEnd

对比后,决定增加多手指触摸原生事件回调。分别为multitouch,multimove,并且增加offdestroy方法,完善后如下:

_touch: function(e) {//...if(e.touches.length > 1) {var point2 = e.touches[1];this.preVector = {x: point2.pageX - this.touch.startX,y: point2.pageY - this.touch.startY}this.startDistance = calcLen(this.preVector);this._emit('multitouch');//增加此回调}
},
_move: function(e) {//...this._emit('move');if(e.touches.length > 1) {//...this._emit('multimove');//增加此回调if(this.preVector.x !== null){//...}//...}
}
off: function(type) {this.handles[type] = [];
},
destroy: function() {this.longTapTimeout && clearTimeout(this.longTapTimeout);this.tapTimeout && clearTimeout(this.tapTimeout);this.target.removeEventListener('touchstart',this._touch);this.target.removeEventListener('touchmove',this._move);this.target.removeEventListener('touchend',this._end);this.target.removeEventListener('touchcancel',this._cancel);this.params = this.handles = this.movetouch = this.pretouch = this.touch = this.longTapTimeout =  null;return false;
},
复制代码

注意:在销毁对象时需要销毁所有的绑定事件,使用removeEventListenner时,需要传入原绑定函数的引用,而bind方法本身会返回一个新的函数,所以构造函数中需要做如下修改:

  function Gesture(target,selector) {//...this._touch = this._touch.bind(this);this._move = this._move.bind(this);this._end = this._end.bind(this);this._cancel = this._cancel.bind(this);this.target.addEventListener('touchstart',this._touch,false);this.target.addEventListener('touchmove',this._move,false);this.target.addEventListener('touchend',this._end,false);this.target.addEventListener('touchcancel',this._cancel,false);}复制代码
  • 增加配置

实际使用中,可能对默认的参数有特殊的要求,比如,长按定义的事件是1000ms而不是800ms,执行swipe移动的距离是50px而不是30,故针对几个特殊的值暴露一个设置接口,同时支持链式调用。逻辑中对应的值则改为对应的参数。


set: function(obj) {for(var i in obj) {if(i === 'distance') this.distance = ~~obj[i];if(i === 'longtapTime') this.longtapTime  = Math.max(500,~~obj[i]);}return this;
}复制代码

使用方法:


new GT('#target').set({longtapTime: 700}).tap(function(){})复制代码
  • 解决冲突

通过具体实例测试后发现在手指滑动的过程(包括move,slide,rotate,pinch等)会和浏览器的窗口滚动手势冲突,一般情况下用e.preventDefault()来阻止浏览器的默认行为。库中通过_emit方法执行回调时params.event为原生的事件对象,但是用params.event.preventDefault()来阻止默认行为是不可行的。因此,需要调整_emit方法,使其接收多一个原生事件对象的参数,执行时最为回调参数范围,供使用时选择性的处理一些默认行为。修改后如下:

_emit: function(type,e){!this.handles[type] && (this.handles[type] = []);if(isTarget(this.e,this.selector) || !this.selector) {for(var i = 0,len = this.handles[type].length; i < len; i++) {typeof this.handles[type][i] === 'function' && this.handles[type][i](e,this.params);}}return true;
}复制代码

响应的库中的调用需要改为this._emit('longtap',e)的形式。

修改后在使用时可以通过e.preventDefault()来阻止默认行为,例如


new GT(el)..on('slide',function(e,params){el.translateX += params.deltaX;el.translateY += params.deltaY;e.preventDefault()
})复制代码

6. 最终结果

最终效果如文章开头展示,可以点击以下链接查看

手机点击此处查看综合实例

手机点击此处查看聊天列表例子

查看缩放和旋转,你可以通过手机扫描二维码或者点击综合实例链接查看效果

所有的源码以及库的使用文档,你可以点击这里查看

所有的问题解决思路和代码均供参考和探讨学习,欢迎指出存在的问题和可以完善的地方。

另外我在掘金上的文章均会同步到我的github上面,内容会持续更新,如果你觉得对你有帮助,谢谢给个star,如果有问题,欢迎提出交流。以下为同步文章的几个地址

1. 深入讲解CSS的一些属性以及实践

2. Javscript相关以及一些工具/库开发思路和源码解读相关

一步步打造一个移动端手势库相关推荐

  1. 移动端手势库设计与实践

    前言 本次给大家分享的是常见的移动端单点触摸事件的设计思路及实践. 核心技术 主要就是利用移动端的以下3个触摸事件,来模拟和实现自定义的手势操作 touchstart:手指触摸到屏幕的一瞬间触发 to ...

  2. 移动端手势库Hammer.js学习

    感觉移动端原生支持的 touch.tap.swipe 几个事件好像还不够用,某些时候还会用到诸如缩放.长按等其他功能. 近日学习了一个手势库 Hammer.js,它是一个轻量级的触屏设备手势库,能识别 ...

  3. 用vue-cli3从0打造一个完整的UI库

    前言 本文旨在给大家提供一种构建一个完整UI库脚手架的思路:包括如何快速并优雅地构建UI库的主页.如何托管主页.如何编写脚本提升自己的开发效率.如何生成CHANGELOG等等,这里提供了一个Demo可 ...

  4. 一个标签的72变,打造一个纯CSS图标库

    每次要用到图标的时候都会到 icono 去copypaste,但每次用到的时候尺寸都各不一样,总是要调整参数,巨烦.当然你可以会想到用zoom.scale来做缩放,但是这样的缩放会使得线宽也变粗了,不 ...

  5. css加号图标_手把手教你打造一个纯CSS图标库

    来,干了这碗安利 写这篇文章的目的其实就是为了安利一下我的图标库: 主题说完了,下面进入正题. 在web开发中,我们经常要用到一些小图标(加减勾叉等).通常做法就两种: 直接使用图片: 使用css/s ...

  6. css加号图标_一个标签的72变,打造一个纯CSS图标库

    每次要用到图标的时候都会到 icono 去copypaste,但每次用到的时候尺寸都各不一样,总是要调整参数,巨烦.当然你可以会想到用zoom.scale来做缩放,但是这样的缩放会使得线宽也变粗了,不 ...

  7. hammer.js 一个多点触摸手势库

    从http://www.cnblogs.com/iamlilinfeng/p/4239957.html处转载 一.什么是hammer.js? hammer.js是一款开源的移动端脚本框架,他可以完美的 ...

  8. css加号图标_一步步打造自己的纯CSS单标签图标库

    原标题:一步步打造自己的纯CSS单标签图标库 作者:深海鱼在掘金 原文:https://juejin.im/post/5a1c21c1f265da430b7af6e5 图标作为网页设计中的一部分,其在 ...

  9. 移动端手势操作--两点同时点击的实现方案

    手机屏幕单点接触是click事件,那两点接触呢?最近项目中的需求是监视手机屏幕的两个手指同时点击事件.类似的需求还是多个手指点击等.技术实现方案很简单,但是由于一个人思路有限,结果绕了一些弯路.记录下 ...

  10. 移动端手势事件 hammer.JS插件

    今天我总结一下我以前用的一个移动端手势的插件 HAMMER.JS插件,很好用,而且提供的手势也很多.它没有任何依赖性,它很小,只有7.34 kB最小化+ gzip压缩!我只是简单的总结了一下他的用法, ...

最新文章

  1. mysql delete删除列,在MySQL中删除我的Key列 (Delete my Key column in MySQL)
  2. Qt 2D绘图功能简单总结
  3. c++输出小数点后几位_2.1 怎么在屏幕上输出各种类型的数据
  4. One order popup window 显示逻辑
  5. extjs获取元素name属性值_【ExtJS】各种获取元素组件方法
  6. 探索流程的奥秘之三, 如何梳理业务流程
  7. python入门——P48魔法方法:迭代器
  8. 如何制作SCI论文中的Figures(一)
  9. 分子量 (Molar Mass,ACM/ICPC Seoul 2007,UVa 1586)
  10. Elasticsearch6.3.0环境安装
  11. php 降低视频分辨率,将低分辨率视频变成1920*1080高分辨视频,可自由调节分辨率宽高...
  12. cmd从网站上下载指定文件
  13. [从零开始学习FPGA编程-22]:进阶篇 - 架构 - FPGA内部硬件电路的设计与建模
  14. 怎样更改itunes备份位置_iTunes备份路径在哪?iTunes备份路径如何修改
  15. MVX-Net: Multimodal VoxelNet for 3D Object Detection
  16. MAC 安装homebrew流程
  17. 微软培植托管增值产业链 SaaS落地面临挑战
  18. Python 爬虫学习笔记(十(2))scrapy爬取图书电商实战详解
  19. 手机录制连续点赞并周期执行(免代码)
  20. c语言判断utf-8中文字符串,C语言中判断一个char*是不是utf8编码分享

热门文章

  1. ios开发网络学习AFN框架的使用一:get和post请求
  2. 做一个消息自动回复,但是回复内容可以在网页上面输入,用input接收,错了,别人有新增选项,本身就是在页面进行新增,页面维护...
  3. IOS 杂笔-14(被人遗忘的owner)
  4. 删除重复记录10.22
  5. MySQL及其图形界面navicat的安装
  6. httpd在嵌入式中应用
  7. ThinkPHP3.2.3完全开发手册离线手册
  8. c#中sqlhelper类的编写(一)
  9. tablelayout高度问题
  10. 各种内部排序算法,C#实现