alloyfinger是一款很是輕量的開源手勢庫,因爲其輕量、基於原生js等特性被普遍使用。關於其原理,它的官方團隊解析的很是詳細——傳送門。相信學太高數的人看起來應該不難,這裏不深刻解析了。html
其核心代碼只有300多行,完成了14個手勢,其手勢並非瀏覽器原生的事件,而是經過監聽touchstart、touchmove、touchend、touchcancel四個原生瀏覽器事件hack出來的手勢,故其用法與原生可能有些不一樣。好比阻止默認事件、阻止冒泡,不能像原生事件那樣用。vue
官方代碼除了alloyfinger的核心庫外還有react、vue的實現。在這裏只對核心庫即vue版本的解析。react
核心庫:android
/* AlloyFinger v0.1.7 * By dntzhang * Github: https://github.com/AlloyTeam/AlloyFinger * Note By keenjaan * Github: https://github.com/keenjaan */
; (function () {
// 計算距離和角度等的數學公式
// 根據兩邊的長度求直角三角形斜邊長度(主要用於求兩點距離)
function getLen(v) {
return Math.sqrt(v.x * v.x + v.y * v.y);
}
// 主要用於計算兩次手勢狀態間的夾角的輔助函數
function dot(v1, v2) {
return v1.x * v2.x + v1.y * v2.y;
}
// 計算兩次手勢狀態間的夾角
function getAngle(v1, v2) {
var mr = getLen(v1) * getLen(v2);
if (mr === 0) return 0;
var r = dot(v1, v2) / mr;
if (r > 1) r = 1;
return Math.acos(r);
}
// 計算夾角的旋轉方向,(逆時針大於0,順時針小於0)
function cross(v1, v2) {
return v1.x * v2.y - v2.x * v1.y;
}
// 將角度轉換爲弧度,而且絕對值
function getRotateAngle(v1, v2) {
var angle = getAngle(v1, v2);
if (cross(v1, v2) > 0) {
angle *= -1;
}
return angle * 180 / Math.PI;
}
// 用於處理手勢監聽函數的構造函數
var HandlerAdmin = function(el) {
this.handlers = []; // 監聽函數列表
this.el = el; // 監聽元素
};
// 構造函數的添加監聽函數的方法
HandlerAdmin.prototype.add = function(handler) {
this.handlers.push(handler);
}
// 構造函數的刪除監聽函數的方法
HandlerAdmin.prototype.del = function(handler) {
if(!handler) this.handlers = []; // handler爲假值時,表明清空監聽函數列表
for(var i=this.handlers.length; i>=0; i--) {
if(this.handlers[i] === handler) {
this.handlers.splice(i, 1);
}
}
}
// 觸發用戶事件監聽回調函數
HandlerAdmin.prototype.dispatch = function() {
for(var i=0,len=this.handlers.length; i<len; i++) {
var handler = this.handlers[i];
if(typeof handler === 'function') handler.apply(this.el, arguments);
}
}
// 實例化處理監聽函數的對象
function wrapFunc(el, handler) {
var handlerAdmin = new HandlerAdmin(el);
handlerAdmin.add(handler); // 添加監聽函數
return handlerAdmin; // 返回實例
}
// 手勢的構造函數
var AlloyFinger = function (el, option) {
this.element = typeof el == 'string' ? document.querySelector(el) : el; // 綁定事件的元素
// 綁定原型上start, move, end, cancel函數的this對象爲 AlloyFinger實例
this.start = this.start.bind(this);
this.move = this.move.bind(this);
this.end = this.end.bind(this);
this.cancel = this.cancel.bind(this);
// 綁定原生的 touchstart, touchmove, touchend, touchcancel事件。
this.element.addEventListener("touchstart", this.start, false);
this.element.addEventListener("touchmove", this.move, false);
this.element.addEventListener("touchend", this.end, false);
this.element.addEventListener("touchcancel", this.cancel, false);
// 保存當有兩個手指以上時,兩個手指間橫縱座標的差值,用於計算兩點距離
this.preV = { x: null, y: null };
this.pinchStartLen = null; // 兩個手指間的距離
this.zoom = 1; // 初始縮放比例
this.isDoubleTap = false; // 是否雙擊
var noop = function () { }; // 空函數,沒有綁定事件時,傳入的函數
// 對14種手勢,分別實例化監聽函數對象,根據option的值添加相關監聽函數,沒有就添加空函數。
this.rotate = wrapFunc(this.element, option.rotate || noop);
this.touchStart = wrapFunc(this.element, option.touchStart || noop);
this.multipointStart = wrapFunc(this.element, option.multipointStart || noop);
this.multipointEnd = wrapFunc(this.element, option.multipointEnd || noop);
this.pinch = wrapFunc(this.element, option.pinch || noop);
this.swipe = wrapFunc(this.element, option.swipe || noop);
this.tap = wrapFunc(this.element, option.tap || noop);
this.doubleTap = wrapFunc(this.element, option.doubleTap || noop);
this.longTap = wrapFunc(this.element, option.longTap || noop);
this.singleTap = wrapFunc(this.element, option.singleTap || noop);
this.pressMove = wrapFunc(this.element, option.pressMove || noop);
this.touchMove = wrapFunc(this.element, option.touchMove || noop);
this.touchEnd = wrapFunc(this.element, option.touchEnd || noop);
this.touchCancel = wrapFunc(this.element, option.touchCancel || noop);
this.delta = null; // 用於判斷是不是雙擊的時間戳
this.last = null; // 記錄時間戳的變量
this.now = null; // 記錄時間戳的變量
this.tapTimeout = null; //tap事件執行的定時器
this.singleTapTimeout = null; // singleTap執行的定時器
this.longTapTimeout = null; // longTap執行的定時器
this.swipeTimeout = null; // swipe執行的定時器
this.x1 = this.x2 = this.y1 = this.y2 = null; // start時手指的座標x1, y1, move時手指的座標x2, y2
this.preTapPosition = { x: null, y: null }; // 記住start時,手指的座標
};
AlloyFinger.prototype = {
start: function (evt) {
if (!evt.touches) return; // touches手指列表,沒有就return
this.now = Date.now(); // 記錄當前事件點
this.x1 = evt.touches[0].pageX; // 第一個手指x座標
this.y1 = evt.touches[0].pageY; // 第一個手指y座標
this.delta = this.now - (this.last || this.now); // 時間戳
this.touchStart.dispatch(evt); // 觸發touchStart事件
if (this.preTapPosition.x !== null) {
// 不是第一次觸摸屏幕時,比較兩次觸摸時間間隔,兩次觸摸間隔小於250ms,觸摸點的距離小於30px時記爲雙擊。
this.isDoubleTap = (this.delta > 0 && this.delta <= 250 && Math.abs(this.preTapPosition.x - this.x1) < 30 && Math.abs(this.preTapPosition.y - this.y1) < 30);
}
this.preTapPosition.x = this.x1; // 將這次的觸摸座標保存到preTapPosition。
this.preTapPosition.y = this.y1;
this.last = this.now; // 記錄本次觸摸時間點
var preV = this.preV, // 獲取記錄的兩點座標差值
len = evt.touches.length; // 手指個數
if (len > 1) { // 手指個數大於1
this._cancelLongTap(); // 取消longTap定時器
this._cancelSingleTap(); // 取消singleTap定時器
var v = { x: evt.touches[1].pageX - this.x1, y: evt.touches[1].pageY - this.y1 };
// 計算兩個手指間橫縱座標差,並保存到prev對象中,也保存到this.preV中。
preV.x = v.x;
preV.y = v.y;
this.pinchStartLen = getLen(preV); // 計算兩個手指的間距
this.multipointStart.dispatch(evt); // 觸發multipointStart事件
}
// 開啓longTap事件定時器,若是750ms內定時器沒有被清除則觸發longTap事件。
this.longTapTimeout = setTimeout(function () {
this.longTap.dispatch(evt);
}.bind(this), 750);
},
move: function (evt) {
if (!evt.touches) return;
var preV = this.preV, // start方法中保存的兩點橫縱座標差值。
len = evt.touches.length, // 手指個數
currentX = evt.touches[0].pageX, // 第一個手指的x座標
currentY = evt.touches[0].pageY; // 第一個手指的y座標
this.isDoubleTap = false; // 移動了就不能是雙擊事件了
if (len > 1) {
// 獲取當前兩點橫縱座標的差值,保存到v對象中。
var v = { x: evt.touches[1].pageX - currentX, y: evt.touches[1].pageY - currentY };
// start保存的preV不爲空,pinchStartLen大於0
if (preV.x !== null) {
if (this.pinchStartLen > 0) {
// 當前兩點的距離除以start中兩點距離,求出縮放比,掛載到evt對象中
evt.zoom = getLen(v) / this.pinchStartLen;
this.pinch.dispatch(evt); // 觸發pinch事件
}
evt.angle = getRotateAngle(v, preV); // 計算旋轉的角度,掛載到evt對象中
this.rotate.dispatch(evt); // 觸發rotate事件
}
preV.x = v.x; // 將move中的兩個手指的橫縱座標差值賦值給preV,同時也改變了this.preV
preV.y = v.y;
} else {
// 出列一根手指的pressMove手勢
// 第一次觸發move時,this.x2爲null,move執行完會有給this.x2賦值。
if (this.x2 !== null) {
// 用本次的move座標減去上一次move座標,獲得x,y方向move距離。
evt.deltaX = currentX - this.x2;
evt.deltaY = currentY - this.y2;
} else {
// 第一次執行move,因此移動距離爲0,將evt.deltaX,evt.deltaY賦值爲0.
evt.deltaX = 0;
evt.deltaY = 0;
}
// 觸發pressMove事件
this.pressMove.dispatch(evt);
}
// 觸發touchMove事件,掛載不一樣的屬性給evt對象拋給用戶
this.touchMove.dispatch(evt);
// 取消長按定時器,750ms內能夠阻止長按事件。
this._cancelLongTap();
this.x2 = currentX; // 記錄當前第一個手指座標
this.y2 = currentY;
if (len > 1) {
evt.preventDefault(); // 兩個手指以上阻止默認事件
}
},
end: function (evt) {
if (!evt.changedTouches) return;
// 取消長按定時器,750ms內會阻止長按事件
this._cancelLongTap();
var self = this; // 保存當前this對象。
// 若是當前留下來的手指數小於2,觸發multipointEnd事件
if (evt.touches.length < 2) {
this.multipointEnd.dispatch(evt);
}
// this.x2或this.y2存在表明觸發了move事件。
// Math.abs(this.x1 - this.x2)表明在x方向移動的距離。
// 故就是在x方向或y方向移動的距離大於30px時則觸發swipe事件
if ((this.x2 && Math.abs(this.x1 - this.x2) > 30) ||
(this.y2 && Math.abs(this.y1 - this.y2) > 30)) {
// 計算swipe的方向並寫入evt對象。
evt.direction = this._swipeDirection(this.x1, this.x2, this.y1, this.y2);
this.swipeTimeout = setTimeout(function () {
self.swipe.dispatch(evt); // 異步觸發swipe事件
}, 0)
} else {
this.tapTimeout = setTimeout(function () {
self.tap.dispatch(evt); // 異步觸發tap事件
// trigger double tap immediately
if (self.isDoubleTap) { // start方法中計算的知足雙擊條件時
self.doubleTap.dispatch(evt); // 觸發雙擊事件
clearTimeout(self.singleTapTimeout); // 清楚singleTap事件定時器
self.isDoubleTap = false; // 重置雙擊條件
}
}, 0)
if (!self.isDoubleTap) { // 若是不知足雙擊條件
self.singleTapTimeout = setTimeout(function () {
self.singleTap.dispatch(evt); // 觸發singleTap事件
}, 250);
}
}
this.touchEnd.dispatch(evt); // 觸發touchEnd事件
// end結束後重置相關的變量
this.preV.x = 0;
this.preV.y = 0;
this.zoom = 1;
this.pinchStartLen = null;
this.x1 = this.x2 = this.y1 = this.y2 = null;
},
cancel: function (evt) {
// 關閉全部定時器
clearTimeout(this.singleTapTimeout);
clearTimeout(this.tapTimeout);
clearTimeout(this.longTapTimeout);
clearTimeout(this.swipeTimeout);
this.touchCancel.dispatch(evt);
},
_cancelLongTap: function () {
clearTimeout(this.longTapTimeout); // 關閉longTap定時器
},
_cancelSingleTap: function () {
clearTimeout(this.singleTapTimeout); // 關閉singleTap定時器
},
_swipeDirection: function (x1, x2, y1, y2) {
// 判斷swipe方向
return Math.abs(x1 - x2) >= Math.abs(y1 - y2) ? (x1 - x2 > 0 ? 'Left' : 'Right') : (y1 - y2 > 0 ? 'Up' : 'Down')
},
// 給14中手勢中一種手勢添加監聽函數
on: function(evt, handler) {
if(this[evt]) { // 事件名在這14中之中,才添加函數到監聽事件中
this[evt].add(handler);
}
},
// 給14中手勢中一種手勢移除監聽函數
off: function(evt, handler) {
if(this[evt]) { // 事件名在這14中之中,才移除相應監聽函數
this[evt].del(handler);
}
},
// 清空,重置全部數據
destroy: function() {
// 關閉全部定時器
if(this.singleTapTimeout) clearTimeout(this.singleTapTimeout);
if(this.tapTimeout) clearTimeout(this.tapTimeout);
if(this.longTapTimeout) clearTimeout(this.longTapTimeout);
if(this.swipeTimeout) clearTimeout(this.swipeTimeout);
// 移除touch的四個事件
this.element.removeEventListener("touchstart", this.start);
this.element.removeEventListener("touchmove", this.move);
this.element.removeEventListener("touchend", this.end);
this.element.removeEventListener("touchcancel", this.cancel);
// 清除全部手勢的監聽函數
this.rotate.del();
this.touchStart.del();
this.multipointStart.del();
this.multipointEnd.del();
this.pinch.del();
this.swipe.del();
this.tap.del();
this.doubleTap.del();
this.longTap.del();
this.singleTap.del();
this.pressMove.del();
this.touchMove.del();
this.touchEnd.del();
this.touchCancel.del();
// 重置全部變量
this.preV = this.pinchStartLen = this.zoom = this.isDoubleTap = this.delta = this.last = this.now = this.tapTimeout = this.singleTapTimeout = this.longTapTimeout = this.swipeTimeout = this.x1 = this.x2 = this.y1 = this.y2 = this.preTapPosition = this.rotate = this.touchStart = this.multipointStart = this.multipointEnd = this.pinch = this.swipe = this.tap = this.doubleTap = this.singleTap = this.pressMove = this.touchMove = this.touchEnd = this.touchCancel = null;
return null;
}
};
// 若是當前環境支持module,exports等es6語法,則導出AlloyFingerPlugin模塊
if (typeof module !== 'undefined' && typeof exports === 'object') {
module.exports = AlloyFinger;
} else { // 不然將AlloyFingerPlugin註冊到全局對象
window.AlloyFinger = AlloyFinger;
}
})();
複製代碼
vue 版本代碼:ios
/* AlloyFinger v0.1.0 for Vue * By june01 * Github: https://github.com/AlloyTeam/AlloyFinger * Note By keenjaan * Github: https://github.com/keenjaan */
; (function() {
var AlloyFingerPlugin = {
// 用於vue掛載指令的install函數
install: function(Vue, options) {
// options掛載指令時傳遞的參數
options = options || {};
// AlloyFinger全局中獲取,沒有就讀取options中獲取。
var AlloyFinger = window.AlloyFinger || options.AlloyFinger;
// 判斷vue的版本
var isVue2 = !!(Vue.version.substr(0,1) == 2);
// 獲取不到AlloyFinger拋出異常
if(!AlloyFinger) {
throw new Error('you need include the AlloyFinger!');
}
// 14中手勢命名
var EVENTMAP = {
'touch-start': 'touchStart',
'touch-move': 'touchMove',
'touch-end': 'touchEnd',
'touch-cancel': 'touchCancel',
'multipoint-start': 'multipointStart',
'multipoint-end': 'multipointEnd',
'tap': 'tap',
'double-tap': 'doubleTap',
'long-tap': 'longTap',
'single-tap': 'singleTap',
'rotate': 'rotate',
'pinch': 'pinch',
'press-move': 'pressMove',
'swipe': 'swipe'
};
// 記錄元素添加監聽事件的數組。
var CACHE = [];
// 建立空對象,用於存放vue自定義指令directive的參數對象
var directiveOpts = {};
// 獲取某個元素在CACHE中是否存在,存在返回index,不存在返回null
var getElemCacheIndex = function(elem) {
for(var i=0,len=CACHE.length; i<len; i++) {
if(CACHE[i].elem === elem) {
return i;
}
}
return null;
};
// 綁定或解綁事件監聽函數
var doOnOrOff = function(cacheObj, options) {
var eventName = options.eventName; // 事件名
var elem = options.elem; // 監聽元素
var func = options.func; // 監聽函數
var oldFunc = options.oldFunc; // dom更新時,舊的監聽函數
// 若是給該元素添加過事件
if(cacheObj && cacheObj.alloyFinger) {
// 若是是dom更新觸發的,不是初始化綁定事件,即oldFunc存在,就解綁上一次綁定的函數oldFunc。
if(cacheObj.alloyFinger.off && oldFunc) cacheObj.alloyFinger.off(eventName, oldFunc);
// 若是func存在,不論是初始化仍是dom更新,都綁定func
if(cacheObj.alloyFinger.on && func) cacheObj.alloyFinger.on(eventName, func);
} else {
// 若是沒有給該元素添加過事件
options = {}; // 建立空對象
options[eventName] = func; // 添加監聽事件的監聽函數
// 向CACHE中添加監聽元素及其監聽的事件和函數
CACHE.push({
elem: elem,
alloyFinger: new AlloyFinger(elem, options) // 初始化AlloyFinger綁定相關事件
});
}
};
// vue 自定義指令的初始化函數
var doBindEvent = function(elem, binding) {
var func = binding.value; // 監聽函數
var oldFunc = binding.oldValue; // 舊的監聽函數
var eventName = binding.arg; // 監聽的事件名
eventName = EVENTMAP[eventName]; // 將事件名轉換爲駝峯法
var cacheObj = CACHE[getElemCacheIndex(elem)]; // 獲取某個元素是否添加過事件監聽,添加到CACHE。
// 觸發事件監聽函數的綁定或移除
doOnOrOff(cacheObj, {
elem: elem,
func: func,
oldFunc: oldFunc,
eventName: eventName
});
};
// 移除事件監聽函數
var doUnbindEvent = function(elem) {
var index = getElemCacheIndex(elem); // 在CACHE中獲取elem的index值
if(!isNaN(index)) { // 若是元素在CACHE中存在
var delArr = CACHE.splice(index, 1); // 刪除該條監聽事件
if(delArr.length && delArr[0] && delArr[0].alloyFinger.destroy) {
delArr[0].alloyFinger.destroy(); // 重置手勢alloyFinger對象,中止全部定時器,移除全部監聽函數,清空全部變量。
}
}
};
// 判斷vue版本
if(isVue2) { // vue2
// directive參數
directiveOpts = {
bind: doBindEvent,
update: doBindEvent,
unbind: doUnbindEvent
};
} else { // vue1
// vue1.xx
directiveOpts = {
update: function(newValue, oldValue) {
var binding = {
value: newValue,
oldValue: oldValue,
arg: this.arg
};
var elem = this.el;
doBindEvent.call(this, elem, binding);
},
unbind: function() {
var elem = this.el;
doUnbindEvent.call(this, elem);
}
}
}
// definition
Vue.directive('finger', directiveOpts); // 綁定自定義指令finger
}
}
// 若是當前環境支持module,exports等es6語法,則導出AlloyFingerPlugin模塊
if(typeof module !== 'undefined' && typeof exports === 'object') {
module.exports = AlloyFingerPlugin;
} else { // 不然將AlloyFingerPlugin註冊到全局對象
window.AlloyFingerVue = AlloyFingerPlugin;
}
})();
複製代碼
上面是整個代碼解析,其中有幾個問題點:git
一、長按是否須要取消tap、swipe、touchend、singleTap、doubleTap等end裏面的全部事件。es6
若是要取消end裏的全部事件,就要添加一個字段isLongTap, 在觸發longTap事件時設置爲true。在end裏判斷isLongTap的值,若是爲true則return掉,阻止end裏的全部事件,並將isLongTap重置爲falsegithub
二、swipe事件和doubleTap的界定,源碼中對swipe與tap的區別是move的距離,當move的距離在x、y方向上都小於等於30px時就爲tap事件,大於30px時就爲swipe事件。doubleTap也同樣,兩次點擊的距離在x、y方向上都小於等於30px,其界定的30px是設置了以下代碼:vue-router
<meta name="viewport" content="width=device-width,initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no">
複製代碼
即設置頁面寬度爲設備的理想視口。在個人實際項目中若是進行如上設置,30px這個值可能有點大,會致使想觸發swipe事件結果變成了tap事件。至於到底多少,大家能夠試一下效果,找到符合大家團隊的分界值。數組
還有就是在實際的移動項目中,咱們可能並不會這樣設置你的視口,好比淘寶團隊的flexible適配。其ios端對頁面視口進行了縮放,android端都是用的理想視口(沒有縮放視口),這樣就形成30px對應到屏幕的滑動距離不一樣。在ios端滑動距離較小就能觸發swipe事件。這種狀況下就不能直接使用,要結合你的移動端適配庫,要對alloyfinger源碼作調整。
關於移動端適配能夠查看個人這篇文章 傳送門
方法一:在alloyfinger源碼中直接讀取viewport的縮放,對於不一樣適配機型設置不一樣的修正值,使得在全部機型上觸發swipe事件,手指移動的距離相同。
方法二:是對於vue版本的實現,經過vue的自定義指令,在掛在指令時,動態的經過參數傳進去。
Vue.use(AlloyFingerVue, option) // 經過參數傳進去。
複製代碼
在AlloyFingerPlugin的install函數中獲取option對象,再將option對象注入到alloyfinger對象中,在alloyfinger中再對swipe的分界值進行修正。 具體實現方案我源碼中已實現,註釋寫的很清楚,不懂能夠問我,源碼連接見文章結尾。
三、阻止冒泡,由於其事件除了touchstart、touchmove、touchend、touchcancel四個原生事件外,其它都是hack的,因此並不能像原生事件那樣在監聽函數中寫阻止冒泡。須要在相應的原生事件中阻止冒泡。在vue版本中能夠經過註冊指令時,傳入參數來阻止冒泡。如:
v-finger:tap.stoppropagation
複製代碼
在doOnOrOff函數中能夠經過modifiers字段讀取到stoppropagation字段,再將stoppropagation字段註冊到alloyfinger對象中。在alloyfinger對象對去該字段來判斷是否須要阻止冒泡。
優勢: 阻止冒泡很是方便,在綁定事件時加一個修飾符便可。
缺點:一旦阻止了冒泡,該元素上全部的事件都阻止了冒泡,若是某一事件須要冒泡,還需特殊處理。
針對以上三點,在官方版本進行了修改。源碼請見 傳送門
最近在項目中遇到了個問題,有些頁面按鈕綁定事件失敗。最後找到了問題,官方的vue版本適配有bug。
當使用vue-router切換路由時,上一個頁面銷燬時,全部綁定事件的元素都會觸發doUnbindEvent函數,當一個元素綁定多個事件時,doUnbindEvent函數會觸發屢次。對於一個元素以下:
<div v-finger:tap="tapFunc" v-finger:long-tap="longTapFunc">按鈕</div>
複製代碼
doUnbindEvent函數:
var doUnbindEvent = function(elem) {
var index = getElemCacheIndex(elem);
if ( index ) {
return true;
}
if(!isNaN(index)) {
var delArr = CACHE.splice(index, 1);
if(delArr.length && delArr[0] && delArr[0].alloyFinger.destroy) {
delArr[0].alloyFinger.destroy();
}
}
};
複製代碼
第一次觸發doUnbindEvent函數, index必定能返回一個number類型數字,會從CACHE中刪除該元素。
當第二次觸發doUnbindEvent時,因爲該元素已被刪除因此index會返回null,而if條件並不能攔截null這個值,
if(!isNaN(index)) {
//
}
故:
delArr = CACHE.splice(index, 1) = CACHE.splice(null, 1) = CACHE.splice(0, 1);
複製代碼
變成了始終截取CACHE數組中第一個元素。
而當路由切換時,上一個頁面觸發doUnbindEvent函數,新頁面觸發doBindEvent函數,而這二者是同時觸發,致使一邊向CACHE數組中添加綁定元素,一邊從CACHE數組中移除元素。當一個元素綁定多個事件時,存在index爲null,會移除新頁面元素剛剛綁定的事件。致使新頁面綁定事件失敗。