一步步打造一個移動端手勢庫

移動端已經爲咱們提供了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相關以及一些工具/庫開發思路和源碼解讀相關

相關文章
相關標籤/搜索