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

深海魚在掘金發表於2018-02-06

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

相關文章