移動端手勢庫設計與實踐

連城發表於2019-03-01

前言

本次給大家分享的是常見的移動端單點觸控事件的設計思路及實踐。

核心技術

主要就是利用移動端的以下3個觸控事件,來模擬和實現自定義的手勢操作

  • touchstart:手指觸控到螢幕的一瞬間觸發
  • touchmove:手指在螢幕上移動時觸發
  • touchend:手指從螢幕上離開時觸發

概念梳理

touch事件觸發時,有3組資料可以獲得觸控資訊,可能大家會對這幾組資料有些混淆,我根據自己的理解來儘量用通俗的語言給大家解釋清楚

  • touches:整個螢幕上所有的觸控點集合
  • targetTouches:當前DOM元素上的所有觸控點集合
  • changedTouches:相對上一次觸控點發生變化的集合

我們先來看一張圖

移動端手勢庫設計與實踐

如圖所示,我們在節點B上繫結touch事件,圓圈代表觸控點。

此時節點B有3個觸控點,即targetTouches陣列有3項,分別儲存著觸控點的資訊,此時touchestargetTouches是相同的。

當我們將手指3移出節點B(始終保持3個手指觸控在螢幕上),那麼touchmove事件觸發,targetTouches只剩2項,而touches依然有3項,此時changedTouches只有一項(因為只有手指3改變了)。

然後我們讓所有手指離開螢幕,那麼此時touchend事件觸發,touches只剩0項,targetTouches剩0項,changedTouches有3項(因為3個手指發生了變化)。

好了,理解這些概念,有助於我們理解程式碼中何時改去哪個touch陣列裡面的值。

思路及實踐

tap

tap可以理解為點選事件,和click不同的是,移動端的click事件有大約300ms的延遲,這是因為瀏覽器要判斷是否為雙擊事件。

思路

  • touchstart:時記錄時間點以及觸控點的x、y座標
  • touchend:計算此時與開始時的時間差,水平和垂直方向的偏移量

說明:時間差用來判斷使用者觸控的時長,超過規定時間則tap事件無效;偏移量用來判斷使用者的觸控事件內是否有過移動的痕跡,這裡我們允許少量的偏移,因為手指可能出現抖動的情況

實現

const tapDefaults = {
  time: 250,
  offset: 10
}

export default function tap (node, a, b) {
  let st, sx, sy
  let opts, callback

  if (typeof a === 'function') {
    callback = a
    opts = Object.assign({}, tapDefaults, b)
  } else {
    callback = b
    opts = Object.assign({}, tapDefaults, a)
  }

  node.addEventListener('touchstart', (e) => {
    e.preventDefault() // 組織瀏覽器預設行為,防止觸控過程頁面滾動

    const touch = e.targetTouches[0]
    st = e.timeStamp
    sx = touch.pageX
    sy = touch.pageY
  }, false)

  node.addEventListener('touchend', (e) => {
    const touch = e.changedTouches[0]

    if (
      // 若為長按,則將時間判定條件更改
      e.timeStamp - st <= opts.time &&
      Math.abs(touch.pageX - sx) <= opts.offset &&
      Math.abs(touch.pageY - sy) <= opts.offset
    ) {
      callback && callback()
    }
  }, false)
}
複製程式碼

doubletap

即雙擊事件,兩次點選時間間隔不超過規定時間則視為有效。

思路

  • 第一次有效點選,記錄該狀態,反之重置狀態
  • 第二次有效點選,觸發事件並重置狀態
  • 若兩次時間間隔過長,重置狀態

實現

const tapDefaults = {
  time: 250,
  offset: 10
}

function handler (node, inject) {
  let st, sx, sy

  node.addEventListener('touchstart', (e) => {
    e.preventDefault()

    const touch = e.targetTouches[0]
    st = e.timeStamp
    sx = touch.pageX
    sy = touch.pageY
  }, false)

  node.addEventListener('touchend', (e) => {
    const touch = e.changedTouches[0]

    inject({
      time: e.timeStamp - st,
      offsetX: Math.abs(touch.pageX - sx),
      offsetY: Math.abs(touch.pageY - sy)
    })
  }, false)
}

export function doubletap (node, a, b) {
  let opts, callback
  let status = 0

  if (typeof a === 'function') {
    callback = a
    opts = Object.assign({}, tapDefaults, b)
  } else {
    callback = b
    opts = Object.assign({}, tapDefaults, a)
  }

  handler(node, (info) => {
    if (
      info.time <= opts.time &&
      info.offsetX <= opts.offset &&
      info.offsetY <= opts.offset
    ) {
      if (status === 0) {
        status = 1
        // 時間間隔太長則重置狀態
        setTimeout(() => {
          status = 0
        }, opts.time)
      } else if (status === 1) {
        callback && callback()
        status = 0
      }
    } else {
      status = 0
    }
  })
}
複製程式碼

longtap

即長按,手指按住超過規定時間視為有效,在手指離開時觸發。

思路

  • 和tap事件思路一樣,只不過時間的判定條件變更一下,改為超過多長時間才觸發

實現

const longtapDefaults = {
  time: 350,
  offset: 10
}

// 這裡程式碼邏輯和tap事件一樣
// 更改時間判定為:
// e.timeStamp - st > opts.time
複製程式碼

press

即按壓事件,按住超過規定時間自動觸發,注意和longtap不同的是,longtap需要等到手指離開時觸發,而press在按壓時間達到規定值,自動觸發,此時手指還在螢幕上。

思路

  • touchstart:記錄此時的x、y座標,並且開啟一個定時器,在規定時間後執行回撥,預設是350ms
  • touchmove:監聽移動過程,在事件觸發前,若出現偏移量過大,則取消定時器
  • touchend:取消定時器

分析:根據以上思路,若按壓時間短,則手指離開時定時器已取消,回撥不會觸發。

實現

const pressDefaults = {
  time: 350,
  offset: 10
}

export default function press (node, a, b) {
  let opts, callback, sx, sy
  let timer = null

  if (typeof a === 'function') {
    callback = a
    opts = Object.assign({}, pressDefaults, b)
  } else {
    callback = b
    opts = Object.assign({}, pressDefaults, a)
  }

  node.addEventListener('touchstart', (e) => {
    e.preventDefault()

    const touch = e.targetTouches[0]
    sx = touch.pageX
    sy = touch.pageY

    timer = setTimeout(() => {
      callback && callback()
    }, opts.time)
  }, false)

  node.addEventListener('touchmove', (e) => {
    const touch = e.targetTouches[0]

    if (
      Math.abs(touch.pageX - sx) > opts.offset ||
      Math.abs(touch.pageY - sy) > opts.offset
    ) {
      clearTimeout(timer)
    }
  }, false)

  node.addEventListener('touchend', () => {
    clearTimeout(timer)
  }, false)
}
複製程式碼

swipe

即手指滑動事件,應用場景如:輪播圖左右滑動切換,整屏頁面滑動翻頁等,算是移動端最常見的手勢之一了。

思路

  • touchstart:記錄時間點和觸控點位置
  • touchmove:實時判斷滑動偏移量
  • touchend:計算滑動速度和方向,條件判定是否觸發事件

分析:考慮到需要在滑動過程做一些動畫特效等操作,因此我們將滑動中的事件暴露給使用者自定義,值得注意的是,若是要實時改變滑塊位置的話,最好不要截流或防抖,截流會造成滑動卡頓的現象,而防抖會出現延遲同步滑動操作的情況;另外對滑動速度也進行了處理,原則上使用者滑動距離超過規定後即視為有效,然而為了更好的使用者體驗,我們判定,如果使用者在短時間內滑動速度非常快的話,也視為一次有效的操作,不一定非要滑動很長的距離

實現

const swipeDefaults = {
  direction: 'horizontal', // vertical
  speed: 200,
  offset: 100,
  prevent: true,
  // touchmove: (offset) => {}
}

export default function swipe (node, a, b) {
  let opts, callback, sTime, sTouch, eTouch

  if (typeof a === 'function') {
    callback = a
    opts = Object.assign({}, swipeDefaults, b)
  } else {
    callback = b
    opts = Object.assign({}, swipeDefaults, a)
  }

  node.addEventListener('touchstart', (e) => {
    if (opts.prevent) {
      e.preventDefault()
    }

    sTime = e.timeStamp
    sTouch = eTouch = e.targetTouches[0]
  }, false)

  if (typeof opts.touchmove === 'function') {
    node.addEventListener('touchmove', (e) => {
      eTouch = e.targetTouches[0]

      if (opts.direction === 'horizontal') {
        opts.touchmove(eTouch.pageX - sTouch.pageX)
      } else {
        opts.touchmove(eTouch.pageY - sTouch.pageY)
      }
    }, false)
  }

  node.addEventListener('touchend', (e) => {
    eTouch = e.changedTouches[0]

    let time = e.timeStamp - sTime
    let offset, direction

    if (opts.direction === 'horizontal') {
      offset = eTouch.pageX - sTouch.pageX
      direction = offset > 0 ? 'right' : 'left'
    } else {
      offset = eTouch.pageY - sTouch.pageY
      direction = offset > 0 ? 'down' : 'up'
    }

    if (
      Math.abs(offset) >= opts.offset ||
      Math.abs(offset) / time * 1000 >= opts.speed
    ) {
      callback && callback(direction)
    }
  }, false)
}
複製程式碼

結束語

通過以上的思路講解和程式碼實現,我們完成了一個單點觸控的移動端手勢庫,是不是迫不及待的想要一睹為快、體驗一番。

最後附上本次分享的原始碼和文件:github.com/ansenhuang/…

相關文章