alloyfinger是一款非常輕量的開源手勢庫,由於其輕量、基於原生js等特性被廣泛使用。關於其原理,它的官方團隊解析的非常詳細——傳送門。相信學過高數的人看起來應該不難,這裡不深入解析了。
其核心程式碼只有300多行,完成了14個手勢,其手勢並不是瀏覽器原生的事件,而是通過監聽touchstart、touchmove、touchend、touchcancel四個原生瀏覽器事件hack出來的手勢,故其用法與原生可能有些不同。比如阻止預設事件、阻止冒泡,不能像原生事件那樣用。
官方程式碼除了alloyfinger的核心庫外還有react、vue的實現。在這裡只對核心庫即vue版本的解析。
核心庫:
/* 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 版本程式碼:
/* 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;
}
})();
複製程式碼
上面是整個程式碼解析,其中有幾個問題點:
1、長按是否需要取消tap、swipe、touchend、singleTap、doubleTap等end裡面的所有事件。
如果要取消end裡的所有事件,就要新增一個欄位isLongTap, 在觸發longTap事件時設定為true。在end裡判斷isLongTap的值,如果為true則return掉,阻止end裡的所有事件,並將isLongTap重置為false
2、swipe事件和doubleTap的界定,原始碼中對swipe與tap的區別是move的距離,當move的距離在x、y方向上都小於等於30px時就為tap事件,大於30px時就為swipe事件。doubleTap也一樣,兩次點選的距離在x、y方向上都小於等於30px,其界定的30px是設定瞭如下程式碼:
<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的分界值進行修正。 具體實現方案我原始碼中已實現,註釋寫的很清楚,不懂可以問我,原始碼連結見文章結尾。
3、阻止冒泡,因為其事件除了touchstart、touchmove、touchend、touchcancel四個原生事件外,其它都是hack的,所以並不能像原生事件那樣在監聽函式中寫阻止冒泡。需要在相應的原生事件中阻止冒泡。在vue版本中可以通過註冊指令時,傳入引數來阻止冒泡。如:
v-finger:tap.stoppropagation
複製程式碼
在doOnOrOff函式中可以通過modifiers欄位讀取到stoppropagation欄位,再將stoppropagation欄位註冊到alloyfinger物件中。在alloyfinger物件對去該欄位來判斷是否需要阻止冒泡。
優點: 阻止冒泡非常方便,在繫結事件時加一個修飾符即可。
缺點:一旦阻止了冒泡,該元素上所有的事件都阻止了冒泡,如果某一事件需要冒泡,還需特殊處理。
針對以上三點,在官方版本進行了修改。原始碼請見 傳送門
官方專案vue版本bug
最近在專案中遇到了個問題,有些頁面按鈕繫結事件失敗。最後找到了問題,官方的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,會移除新頁面元素剛剛繫結的事件。導致新頁面繫結事件失敗。