一步步打造一个移动端手势库

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

聊天列表实例css

综合实例 html

若是你想看缩放和旋转效果能够点击上面连接或经过手机扫描二维码查看效果java

常见的事件和手势

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

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

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

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

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

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

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相关以及一些工具/库开发思路和源码解读相关