Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

如何实现swipe、tap、longTap等自定义事件 #21

Open
qianlongo opened this issue Apr 29, 2018 · 0 comments
Open

如何实现swipe、tap、longTap等自定义事件 #21

qianlongo opened this issue Apr 29, 2018 · 0 comments
Labels

Comments

@qianlongo
Copy link
Owner

前言

移动端原生支持touchstarttouchmovetouchend等事件,但是在平常业务中我们经常需要使用swipetapdoubleTaplongTap等事件去实现想要的效果,对于这种自定义事件他们底层是如何实现的呢?让我们从Zepto.jstouch模块去分析其原理。您也可以直接查看touch.js源码注释

源码仓库

原文链接

touch

事件简述

Zepto的touch模块实现了很多与手势相关的自定义事件,分别是swipe, swipeLeft, swipeRight, swipeUp, swipeDown,doubleTap, tap, singleTap, longTap

事件名称 事件描述
swipe 滑动事件
swipeLeft ←左滑事件
swipeRight →右滑事件
swipeUp ↑上滑事件
swipeDown ↓下滑事件
doubleTap 双击事件
tap 点击事件(非原生click事件)
singleTap 单击事件
longTap 长按事件
;['swipe', 'swipeLeft', 'swipeRight', 'swipeUp', 'swipeDown', 'doubleTap', 'tap', 'singleTap', 'longTap'].forEach(function(eventName){
  $.fn[eventName] = function(callback){ return this.on(eventName, callback) }
})

可以看到Zepto把这些方法都挂载到了原型上,这意味着,你可以直接用简写的方式例如$('body').tap(callback)

前置条件

在开始分析这些事件如何实现之前,我们先了解一些前置条件

  • 部分内部变量
var touch = {},
    touchTimeout, tapTimeout, swipeTimeout, longTapTimeout,
    // 长按事件定时器时间
    longTapDelay = 750,
    gesture

touch: 用以存储手指操作的相关信息,例如手指按下时的位置,离开时的坐标等。

touchTimeout,tapTimeout, swipeTimeout,longTapTimeout分别存储singleTap、tap、swipe、longTap事件的定时器。

longTapDelay:longTap事件定时器延时时间

gesture: 存储ieGesture事件对象

  • 滑动方向判断(swipeDirection)

我们根据下图以及对应的代码来理解滑动的时候方向是如何判定的。需要注意的是浏览器中的“坐标系”和数学中的坐标系还是不太一样,Y轴有点反过来的意思。

手机屏幕坐标图

/**
  * 判断移动的方向,结果是Left, Right, Up, Down中的一个
  * @param  {} x1 起点的横坐标
  * @param  {} x2 终点的横坐标
  * @param  {} y1 起点的纵坐标
  * @param  {} y2 终点的纵坐标
  */

function swipeDirection(x1, x2, y1, y2) {
  /**
    * 1. 第一个三元运算符得到如果x轴滑动的距离比y轴大,那么是左右滑动,否则是上下滑动
    * 2. 如果是左右滑动,起点比终点大那么往左滑动
    * 3. 如果是上下滑动,起点比终点大那么往上滑动
    * 需要注意的是这里的坐标和数学中的有些不一定 纵坐标有点反过来的意思
    * 起点p1(1, 0) 终点p2(1, 1)
    */
  return Math.abs(x1 - x2) >=
    Math.abs(y1 - y2) ? (x1 - x2 > 0 ? 'Left' : 'Right') : (y1 - y2 > 0 ? 'Up' : 'Down')
}
  • 触发长按事件
function longTap() {
  longTapTimeout = null
  if (touch.last) {
    // 触发el元素的longTap事件
    touch.el.trigger('longTap')
    touch = {}
  }
}

在触发长按事件之前先将longTapTimeout定时器取消,如果touch.last还存在则触发之,为什么要判断touch.last呢,因为swip, doubleTap,singleTap会将touch对象置空,当这些事件发生的时候,自然不应该发生长按事件。

  • 取消长按,以及取消所有事件
// 取消长按
function cancelLongTap() {
  if (longTapTimeout) clearTimeout(longTapTimeout)
  longTapTimeout = null
}

// 取消所有事件

function cancelAll() {
  if (touchTimeout) clearTimeout(touchTimeout)
  if (tapTimeout) clearTimeout(tapTimeout)
  if (swipeTimeout) clearTimeout(swipeTimeout)
  if (longTapTimeout) clearTimeout(longTapTimeout)
  touchTimeout = tapTimeout = swipeTimeout = longTapTimeout = null
  touch = {}
}

方式都是类似,先调用clearTimeout取消定时器,然后释放对应的变量,等候垃圾回收。

整体结构分析

$(document).ready(function(){
  /**
    * now 当前触摸时间
    * delta 两次触摸的时间差
    * deltaX x轴变化量
    * deltaY Y轴变化量
    * firstTouch 触摸点相关信息
    * _isPointerType 是否是pointerType
    */
  var now, delta, deltaX = 0, deltaY = 0, firstTouch, _isPointerType

  $(document)
    .bind('MSGestureEnd', function(e){
      // xxx 先不看这里
    })
    .on('touchstart MSPointerDown pointerdown', function(e){
      // xxx 关注这里
    })
    .on('touchmove MSPointerMove pointermove', function(e){
      // xxx 关注这里
    })
    .on('touchend MSPointerUp pointerup', function(e){
      // xxx 关注这里
    })
    .on('touchcancel MSPointerCancel pointercancel', cancelAll)

    $(window).on('scroll', cancelAll)
  })

这里将详细代码暂时省略了,留出整体框架,可以看出Zepto在dom,ready的时候在document上添加了MSGestureEnd,touchstart MSPointerDown pointerdown,touchmove MSPointerMove pointermove,touchcancel MSPointerCancel pointercancel等事件,最后还给在window上加了scroll事件。我们将目光聚焦在touchstart,touchmove,touchend对应的逻辑,其他相对少见的事件在暂不讨论

touchstart

if((_isPointerType = isPointerEventType(e, 'down')) 
&& !isPrimaryTouch(e)) return

要走到touchstart事件处理程序后续逻辑中,需要先满足一些条件。到底是哪些条件呢?先来看看isPointerEventType, isPrimaryTouch两个函数做了些什么。

**isPointerEventType

function isPointerEventType(e, type){
  return (e.type == 'pointer'+type ||
    e.type.toLowerCase() == 'mspointer'+type)
}

Pointer Event相关知识点击这里

isPrimaryTouch

function isPrimaryTouch(event){
  return (event.pointerType == 'touch' ||
    event.pointerType == event.MSPOINTER_TYPE_TOUCH)
    && event.isPrimary
}

根据mdn pointerType,其类型可以是mouse,pen,touch,这里只处理其值为touch并且isPrimary为true的情况。

接着回到

if((_isPointerType = isPointerEventType(e, 'down')) 
&& !isPrimaryTouch(e)) return

其实就是过滤掉非触摸事件。

触摸点信息兼容处理

// 如果是pointerdown事件则firstTouch保存为e,否则是e.touches第一个
firstTouch = _isPointerType ? e : e.touches[0]

这里只清楚e.touches[0]的处理逻辑,另一种不太明白,望有知晓的同学告知一下,感谢感谢。

复原终点坐标

// 一般情况下,在touchend或者cancel的时候,会将其清除,如果用户调阻止了默认事件,则有可能清空不了,但是为什么要将终点坐标清除呢?
if (e.touches && e.touches.length === 1 && touch.x2) {
  // Clear out touch movement data if we have it sticking around
  // This can occur if touchcancel doesn't fire due to preventDefault, etc.
  touch.x2 = undefined
  touch.y2 = undefined
}

存储触摸点部分信息

// 保存当前时间
now = Date.now()
// 保存两次点击时候的时间间隔,主要用作双击事件
delta = now - (touch.last || now)
// touch.el 保存目标节点
// 不是标签节点则使用该节点的父节点,注意有伪元素
touch.el = $('tagName' in firstTouch.target ?
  firstTouch.target : firstTouch.target.parentNode)
// touchTimeout 存在则清除之,可以避免重复触发
touchTimeout && clearTimeout(touchTimeout)
// 记录起始点坐标(x1, y1)(x轴,y轴)
touch.x1 = firstTouch.pageX
touch.y1 = firstTouch.pageY

判断双击事件

// 两次点击的时间间隔 > 0 且 < 250 毫秒,则当做doubleTap事件处理
if (delta > 0 && delta <= 250) touch.isDoubleTap = true

处理长按事件

// 将now设置为touch.last,方便上面可以计算两次点击的时间差
touch.last = now
// longTapDelay(750毫秒)后触发长按事件
longTapTimeout = setTimeout(longTap, longTapDelay)

touchmove

.on('touchmove MSPointerMove pointermove', function(e){
  if((_isPointerType = isPointerEventType(e, 'move')) &&
    !isPrimaryTouch(e)) return
  firstTouch = _isPointerType ? e : e.touches[0]
  // 取消长按事件,都移动了,当然不是长按了
  cancelLongTap()
  // 终点坐标 (x2, y2)
  touch.x2 = firstTouch.pageX
  touch.y2 = firstTouch.pageY
  // 分别记录X轴和Y轴的变化量
  deltaX += Math.abs(touch.x1 - touch.x2)
  deltaY += Math.abs(touch.y1 - touch.y2)
})

手指移动的时候,做了三件事情。

  1. 取消长按事件
  2. 记录终点坐标
  3. 记录x轴和y轴的移动变化量

touchend

.on('touchend MSPointerUp pointerup', function(e){
  if((_isPointerType = isPointerEventType(e, 'up')) &&
    !isPrimaryTouch(e)) return
  // 取消长按事件  
  cancelLongTap()
  // 滑动事件,只要X轴或者Y轴的起始点和终点的距离超过30则认为是滑动,并触发滑动(swip)事件,
  // 紧接着马上触发对应方向的swip事件(swipLeft, swipRight, swipUp, swipDown)
  // swipe
  if ((touch.x2 && Math.abs(touch.x1 - touch.x2) > 30) ||
      (touch.y2 && Math.abs(touch.y1 - touch.y2) > 30))

    swipeTimeout = setTimeout(function() {
      if (touch.el){
        touch.el.trigger('swipe')
        touch.el.trigger('swipe' + (swipeDirection(touch.x1, touch.x2, touch.y1, touch.y2)))
      }
      touch = {}
    }, 0)
  // touch对象的last属性,在touchstart事件中添加,所以触发了start事件便会存在  
  // normal tap
  else if ('last' in touch)
    // don't fire tap when delta position changed by more than 30 pixels,
    // for instance when moving to a point and back to origin
    // 只有当X轴和Y轴的变化量都小于30的时候,才认为有可能触发tap事件
    if (deltaX < 30 && deltaY < 30) {
      // delay by one tick so we can cancel the 'tap' event if 'scroll' fires
      // ('tap' fires before 'scroll')
      tapTimeout = setTimeout(function() {

        // trigger universal 'tap' with the option to cancelTouch()
        // (cancelTouch cancels processing of single vs double taps for faster 'tap' response)
        // 创建自定义事件
        var event = $.Event('tap')
        // 往自定义事件中添加cancelTouch回调函数,这样使用者可以通过该方法取消所有的事件
        event.cancelTouch = cancelAll
        // [by paper] fix -> "TypeError: 'undefined' is not an object (evaluating 'touch.el.trigger'), when double tap
        // 当目标元素存在,触发tap自定义事件
        if (touch.el) touch.el.trigger(event)

        // trigger double tap immediately
        // 如果是doubleTap事件,则触发之,并清除touch
        if (touch.isDoubleTap) {
          if (touch.el) touch.el.trigger('doubleTap')
          touch = {}
        }

        // trigger single tap after 250ms of inactivity
        // 否则在250毫秒之后。触发单击事件
        else {
          touchTimeout = setTimeout(function(){
            touchTimeout = null
            if (touch.el) touch.el.trigger('singleTap')
            touch = {}
          }, 250)
        }
      }, 0)
    } else {
      // 不是tap相关的事件
      touch = {}
    }
    // 最后将变化量信息清空
    deltaX = deltaY = 0

})

touchend事件触发时,相应的注释都在上面了,但是我们来分解一下这段代码。

swip事件相关

if ((touch.x2 && Math.abs(touch.x1 - touch.x2) > 30) ||
  (touch.y2 && Math.abs(touch.y1 - touch.y2) > 30))

swipeTimeout = setTimeout(function() {
  if (touch.el){
    touch.el.trigger('swipe')
    touch.el.trigger('swipe' + (swipeDirection(touch.x1, touch.x2, touch.y1, touch.y2)))
  }
  touch = {}
}, 0)

手指离开后,通过判断x轴或者y轴的位移,只要其中一个跨度大于30便会触发swip及其对应方向的事件。

tap,doubleTap,singleTap

这三个事件可能触发的前提条件是touch对象中还存在last属性,从touchstart事件处理程序中知道last在其中记录,而在touchend之前被清除的时机是长按事件被触发longTap,取消所有事件被调用cancelAll

if (deltaX < 30 && deltaY < 30) {
  // delay by one tick so we can cancel the 'tap' event if 'scroll' fires
  // ('tap' fires before 'scroll')
  tapTimeout = setTimeout(function() {

    // trigger universal 'tap' with the option to cancelTouch()
    // (cancelTouch cancels processing of single vs double taps for faster 'tap' response)
    var event = $.Event('tap')
    event.cancelTouch = cancelAll
    // [by paper] fix -> "TypeError: 'undefined' is not an object (evaluating 'touch.el.trigger'), when double tap
    if (touch.el) touch.el.trigger(event)
  }    
}

只有当x轴和y轴的变化量都小于30的时候才会触发tap事件,注意在触发tap事件之前,Zepto还将往事件对象上添加了cancelTouch属性,对应的也就是cancelAll方法,即你可以通过他取消所有的touch相关事件。

// trigger double tap immediately

if (touch.isDoubleTap) {
  if (touch.el) touch.el.trigger('doubleTap')
  touch = {}
}

// trigger single tap after 250ms of inactivity

else {
  touchTimeout = setTimeout(function(){
    touchTimeout = null
    if (touch.el) touch.el.trigger('singleTap')
    touch = {}
  }, 250)
}

在发生触发tap事件之后,如果是doubleTap,则会紧接着触发doubleTap事件,否则250毫秒之后触发singleTap事件,并且都会讲touch对象置为空对象,以便下次使用

// 最后将变化量信息清空
deltaX = deltaY = 0

touchcancel

.on('touchcancel MSPointerCancel pointercancel', cancelAll)

touchcancel被触发的时候,取消所有的事件。

scroll

$(window).on('scroll', cancelAll)

当滚动事件被触发的时候,取消所有的事件(这里有些不解,滚动事件触发,完全有可能是要触发tap或者swip等事件啊)。

结尾

最后说一个面试中经常会问的问题,touch击穿现象。如果对此有兴趣可以查看移动端click延迟及zepto的穿透现象, 新年第一发--深入不浅出zepto的Tap击穿问题

参考

  1. 移动端click延迟及zepto的穿透现象

  2. 新年第一发--深入不浅出zepto的Tap击穿问题

  3. 读Zepto源码之Touch模块

  4. pointerType

  5. [翻译]整合鼠标、触摸 和触控笔事件的Html5 Pointer Event Api

文章目录

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant