Skip to content
文章目录

PointerEvent指针事件

指针事件

早期的浏览器,只存在鼠标事件(MouseEvent)。后来,以智能手机和平板电脑为首的触屏设备开始普及,交互方式发生了改变。但为了使现有功能不受影响,在很多情况下,触摸事件和鼠标事件会相继触发(以使非触摸专用的代码仍然可以与用户交互)。例如轻触屏幕会触发 touchstart 事件,如果不调用 event.preventDefault()会继续触发 mousedown 事件。但在面对多点触控的时候,鼠标事件就显得无能为力了。因此,引入了触摸事件(TouchEvent)。不过这还不够完美,因为很多其他输入设备(如触控笔)有自己的特性。如果此时推出基于触控笔的 API,那后面万一又有新特性的输入设备出现时,又怎么办呢?而且同时维护两份处理鼠标事件和触摸事件的代码已经很笨重了。面对这些问题,W3C 急需一套能够整合输入事件的 API,指针事件应运而生。指针事件(PointerEvent)是 HTML5 的事件规范之一,它主要目的是用来将鼠标(Mouse),触摸(Touch)和触控笔(Pen)三种事件整合为统一的 API。

指针事件属性

指针事件属性继承自 MouseEvent 和 Event。常用属性例如:clientX,clientY 等都有。下面简单介绍指针事件独有的属性。

属性介绍
pointerId触发事件的指针的唯一提示
width指针的接触面的 CSS 像素宽度
height指针的接触面的 CSS 像素高度
pressure归一化后的指针压力值,范围在 0-1 之间
tangentialPressure归一化后的切向压力值,范围在-1-1]之间,0 表示控制设备中立状态时的值
tiltX由输入设备(如手写笔)与 Y 轴的构成平面,和 YZ 平面之间的夹角,范围在-90-90 之间
tiltY由输入设备(如手写笔)与 X 轴构成平面,和 XZ 平面之间的夹角,范围在-90-90 之间
twist输入设备(如手写笔)围绕自身价值范围旋转的角度,范围在 0-359 之间
pointerType表示触发事件的设备类型,mouse,pen,touch
isPrimary表示一个指针是否是当前设备类型的主指针

指针事件类型

MouseEventTouchEventPointerEvent
mousedowntouchstartpointerdown
mousemovetouchmovepointermove
mouseuptouchendpointerup
touchcancelpointercancel
mouseenterpointerenter
mouseleavepointerleave
mouseoverpointerover
mouseoutpointerout
gotpointercapture
lostpointercapture

使用方式

MouseEvent

我们一般对于 MouseEvent 事件会这样处理:

html
<html>
  <body>
    <div id="box"></div>
  </body>
  <script>
    const box = document.getElementById('box')
    let isMouseDown = false
    // 将mousedown事件绑定到box元素上
    box.addEventListener('mousedown', function (e) {
      isMouseDown = true
    })
    // 将mousemove事件绑定到document或window上,防止移动过快丢失目标元素
    document.addEventListener('mousemove', function (e) {
      if (isMouseDown) {
        // todo
      }
    })
    // 将mouseup事件绑定到document或window上,防止在目标元素外释放鼠标
    document.addEventListener('mouseup', function (e) {
      isMouseDown = false
    })
  </script>
</html>

上述处理方式的问题在于,鼠标在文档周围的移动可能会引起副作用,触发其他元素的事件处理程序。

TouchEvent

html
<html>
  <body>
    <div id="box"></div>
  </body>
  <script type="text/javascript">
    const box = document.getElementById('box')
    let point = { x: 0, y: 0 }
    let point2 = { x: 0, y: 0 }

    box.addEventListener('touchstart', function (e) {
      point = { x: e.touches[0].clientX, y: e.touches[0].clientY }
      // 第二个触摸点
      if (pointers.length > 1) {
        point2 = { x: e.touches[1].clientX, y: e.touches[1].clientY }
      }
    })

    box.addEventListener('touchmove', function (e) {
      const current = { x: e.touches[0].clientX, y: e.touches[0].clientY }
      // 第二个触摸点
      if (e.touches.length > 1) {
        const current2 = { x: e.touches[1].clientX, y: e.touches[1].clientY }
      }
    })

    box.addEventListener('touchend', function (e) {})

    box.addEventListener('touchcancel', function (e) {})
  </script>
</html>

相对于鼠标事件需要将 mousemovemouseup 绑定到 document 上,防止丢失目标元素的问题,在触摸事件中则不会发生。因为 touchmovetouchendtouchcancel 事件的目标和触发 touchstart 事件的目标元素相同。

PointerEvent

对于MouseEvent存在的问题,指针事件有对应的解决方法,就是Element.setPointerCapture()。我们可以在pointerdown事件处理程序中调用box.setPointerCapture(e.pointerId),这样接下来所发生的事件(例如pointerenterpointerleavepointeroutpointeroverpointeruppointercancel)都会被重定向到 box 上。具体使用如下:

html
<html>
  <body>
    <div id="box"></div>
  </body>
  <script type="text/javascript">
    const box = document.getElementById('box')
    let isPointerDown = false
    box.addEventListener('pointerdown', function (e) {
      isPointerDown = true
    })
    box.addEventListener('pointermove', function (e) {
      box.setPointerCapture(e.pointerId)
      if (isPointerDown) {
        // todo
      }
    })
    box.addEventListener('pointerup', function (e) {
      isPointerDown = false
    })
    box.addEventListener('pointercancel', function (e) {
      isPointerDown = false
    })
  </script>
</html>

提示

如果在移动端使用,请给 box 元素添加 touch-action: none;

多点触控

我们使用 TouchEvent 实现多点触控的时候,event 会返回touches属性,该属性会列出所有当前在与触摸表面接触的 Touch 对象,不管触摸点是否已经改变或其目标元素是在处于 touchstart 阶段。但指针事件并没有类似的属性,而是需要我们自己处理。具体实现如下:

html
<html>
  <body>
    <div id="box"></div>
  </body>
  <script type="text/javascript">
    const box = document.getElementById('box')
    let pointers = []
    let point = { x: 0, y: 0 }
    let point2 = { x: 0, y: 0 }
    box.addEventListener('pointerdown', function (e) {
      // 维护一个数组,用于记录当前触摸点
      pointers.push(e)
      point = { x: pointers[0].clientX, y: pointers[0].clientY }
      // 第二个触摸点
      if (pointers.length > 1) {
        point2 = { x: pointers[1].clientX, y: pointers[1].clientY }
      }
    })
    box.addEventListener('pointermove', function (e) {
      handlePointers(e, 'update')
      const current = { x: pointers[0].clientX, y: pointers[0].clientY }
      if (pointers.length > 1) {
        const current2 = { x: pointers[1].clientX, y: pointers[1].clientY }
      }
    })
    box.addEventListener('pointerup', function (e) {
      handlePointers(e, 'delete')
    })
    box.addEventListener('pointercancel', function (e) {
      pointers = []
    })

    /**
     * 处理指针
     * @param {PointerEvent} e
     * @param {string} type
     */
    function handlePointers(e, type) {
      for (let i = 0; i <script pointers.length; i++) {
        if (pointers[i].pointerId === e.pointerId) {
          if (type === 'update') {
            pointers[i] = e
          } else if (type === 'delete') {
            pointers.splice(i, 1)
          }
        }
      }
    }
  </script>
</html>

双指缩放原理

双指向外扩张表示放大,向内收缩表示缩小,缩放比例是通过计算双指当前的距离 / 双指上一次的距离获得的。详见下图:

计算出缩放比例后再通过 transform 进行缩放

两点间距离公式

设两个点 A、B 以及坐标分别为 A(x1, y1)、B(x2, y2),则 A 和 B 两点之间的距离为:

js
/**
 * 获取两点间距离
 * @param {object} a 第一个点坐标
 * @param {object} b 第二个点坐标
 * @returns
 */
function getDistance(a, b) {
  const x = a.x - b.x
  const y = a.y - b.y
  return Math.hypot(x, y) // Math.sqrt(x * x + y * y);
}

中点坐标公式

设两个点 A、B 以及坐标分别为 A(x1, y1)、B(x2, y2),则 A 和 B 两点的中点 P 的坐标为:

js
/**
 * 获取中点坐标
 * @param {object} a 第一个点坐标
 * @param {object} b 第二个点坐标
 * @returns
 */
function getCenter(a, b) {
  const x = (a.x + b.x) / 2
  const y = (a.y + b.y) / 2
  return { x: x, y: y }
}

获取图片缩放尺寸

js
const image = document.getElementById('image')

let result, // 图片缩放宽高
  x, // x轴偏移量
  y, // y轴偏移量
  scale = 1, // 缩放比例
  maxScale,
  minScale = 0.5

// 由于图片是异步加载,需要在load方法里获取naturalWidth,naturalHeight
image.addEventListener('load', function () {
  result = getImgSize(image.naturalWidth, image.naturalHeight, window.innerWidth, window.innerHeight)
  maxScale = Math.max(Math.round(image.naturalWidth / result.width), 3)
  // 图片宽高
  image.style.width = result.width + 'px'
  image.style.height = result.height + 'px'
  // 垂直水平居中显示
  x = (window.innerWidth - result.width) * 0.5
  y = (window.innerHeight - result.height) * 0.5
  image.style.transform = 'translate3d(' + x + 'px, ' + y + 'px, 0) scale(1)'
})

// 图片赋值需放在load回调之后,因为图片缓存后读取很快,有可能不执行load回调
image.src = '../images/xxx.jpg'

/**
 * 获取图片缩放尺寸
 * @param {number} naturalWidth
 * @param {number} naturalHeight
 * @param {number} maxWidth
 * @param {number} maxHeight
 * @returns
 */
function getImgSize(naturalWidth, naturalHeight, maxWidth, maxHeight) {
  const imgRatio = naturalWidth / naturalHeight
  const maxRatio = maxWidth / maxHeight
  let width, height
  // 如果图片实际宽高比例 >= 显示宽高比例
  if (imgRatio >= maxRatio) {
    if (naturalWidth > maxWidth) {
      width = maxWidth
      height = (maxWidth / naturalWidth) * naturalHeight
    } else {
      width = naturalWidth
      height = naturalHeight
    }
  } else {
    if (naturalHeight > maxHeight) {
      width = (maxHeight / naturalHeight) * naturalWidth
      height = maxHeight
    } else {
      width = naturalWidth
      height = naturalHeight
    }
  }
  return { width: width, height: height }
}

双指行为的判断

js
document.addEventListener('touchmove', function (event) {
  if (event.touches && event.touches.length == 2) {
    console.log('双指在移动')
  }
})

event.touches 中的每一项都是触摸点对象,包含了触碰的元素以及触摸点的坐标。

触摸点的坐标就可以用来计算缩放比例。

我们记录下第一次双指行为发生时候的两个手指点的坐标值,于是就可以计算出这两个指头之间的距离;

然后继续 touchmove 的时候,实时计算出新的两个手指的坐标距离。

一开始的距离/现在的距离 = 应该缩放的比例。

实例

js
var eleImg = document.querySelector('#image')
var store = {
  scale: 1,
}
// 缩放事件的处理
eleImg.addEventListener('touchstart', function (event) {
  var touches = event.touches
  var events = touches[0]
  var events2 = touches[1]

  event.preventDefault()

  // 第一个触摸点的坐标
  store.pageX = events.pageX
  store.pageY = events.pageY

  store.moveable = true

  if (events2) {
    store.pageX2 = events2.pageX
    store.pageY2 = events2.pageY
  }

  store.originScale = store.scale || 1
})
document.addEventListener('touchmove', function (event) {
  if (!store.moveable) {
    return
  }

  event.preventDefault()

  var touches = event.touches
  var events = touches[0]
  var events2 = touches[1]
  // 双指移动
  if (events2) {
    // 第2个指头坐标在touchmove时候获取
    if (!store.pageX2) {
      store.pageX2 = events2.pageX
    }
    if (!store.pageY2) {
      store.pageY2 = events2.pageY
    }

    // 获取坐标之间的举例
    var getDistance = function (start, stop) {
      return Math.hypot(stop.x - start.x, stop.y - start.y)
    }
    // 双指缩放比例计算
    var zoom =
      getDistance(
        {
          x: events.pageX,
          y: events.pageY,
        },
        {
          x: events2.pageX,
          y: events2.pageY,
        }
      ) /
      getDistance(
        {
          x: store.pageX,
          y: store.pageY,
        },
        {
          x: store.pageX2,
          y: store.pageY2,
        }
      )
    // 应用在元素上的缩放比例
    var newScale = store.originScale * zoom
    // 最大缩放比例限制
    if (newScale > 3) {
      newScale = 3
    }
    // 记住使用的缩放值
    store.scale = newScale
    // 图像应用缩放效果
    eleImg.style.transform = 'scale(' + newScale + ')'
  }
})

document.addEventListener('touchend', function () {
  store.moveable = false

  delete store.pageX2
  delete store.pageY2
})
document.addEventListener('touchcancel', function () {
  store.moveable = false

  delete store.pageX2
  delete store.pageY2
})

参考资料

js PointerEvent 指针事件简单介绍

js 实现双指缩放

移动端双指缩放图片 JS 事件的实践心得

移动端 js 实现双指缩放,单指拖动图片

移动端 js 实现双指缩放,单指拖动图片