触摸事件

为了高质量地支持基于触摸的用户界面,触摸事件提供了解释触摸屏或触控板上手指(或触控笔)活动的能力。

触摸事件接口是相对底层的 API,可用于支持特定于应用程序的多点触控交互,例如双指手势。当手指(或触控笔)首次接触接触面时,多点触控交互开始。之后,其他手指可能会接触表面,并可选择地在触摸表面上移动。当手指离开表面时,交互结束。在此交互过程中,应用程序会在开始、移动和结束阶段接收触摸事件。

触摸事件类似于鼠标事件,但它们支持同时在触摸表面的不同位置进行触摸。TouchEvent 接口封装了当前活动的所有触摸点。Touch 接口代表单个触摸点,包含有关触摸点相对于浏览器视口的位置等信息。

定义

表面

支持触摸的表面。这可能是屏幕或触控板。

触摸点

与表面的接触点。这可能是手指(或肘部、耳朵、鼻子,随便什么,但通常是手指)或触控笔。

接口

TouchEvent

表示当表面上的触摸状态发生变化时发生的事件。

Touch

表示用户与触摸表面之间的单个接触点。

TouchList

表示一组触摸;当用户同时有多根手指放在表面上时,会使用此选项。

示例

此示例一次跟踪多个触摸点,允许用户同时使用多根手指在 <canvas> 中绘图。它仅在支持触摸事件的浏览器上有效。

注意: 下面的文本在描述与表面的接触时使用了“手指”一词,但当然也可以是触控笔或其他接触方式。

创建画布

html
<canvas id="canvas" width="600" height="600">
  Your browser does not support canvas element.
</canvas>
<br />
Log:
<pre id="log"></pre>
css
#canvas {
  border: 1px solid black;
}

#log {
  height: 200px;
  width: 600px;
  overflow: scroll;
  border: 1px solid #cccccc;
}

设置事件处理程序

代码为我们的 <canvas> 元素设置了所有事件监听器,以便我们能够处理发生的触摸事件。

js
const el = document.getElementById("canvas");
el.addEventListener("touchstart", handleStart);
el.addEventListener("touchend", handleEnd);
el.addEventListener("touchcancel", handleCancel);
el.addEventListener("touchmove", handleMove);

跟踪新的触摸

我们将跟踪正在进行的触摸。

js
const ongoingTouches = [];

当发生 touchstart 事件(表示在表面上发生了新的触摸)时,会调用下面的 handleStart() 函数。

js
function handleStart(evt) {
  evt.preventDefault();
  log("touchstart.");
  const el = document.getElementById("canvas");
  const ctx = el.getContext("2d");
  const touches = evt.changedTouches;

  for (let i = 0; i < touches.length; i++) {
    const touch = touches[i];
    log(`touchstart: ${i}.`);
    ongoingTouches.push(copyTouch(touch));
    const color = colorForTouch(touch);
    log(`color of touch with id ${touch.identifier} = ${color}`);
    ctx.beginPath();
    ctx.arc(touch.pageX, touch.pageY, 4, 0, 2 * Math.PI, false); // a circle at the start
    ctx.fillStyle = color;
    ctx.fill();
  }
}

这会调用 event.preventDefault() 来阻止浏览器继续处理触摸事件(这也防止了鼠标事件也被传递)。然后我们获取上下文,并从事件的 TouchEvent.changedTouches 属性中提取已更改的触摸点列表。

之后,我们遍历列表中的所有 Touch 对象,将它们推入一个活动触摸点数组,并绘制绘制的起始点作为一个小圆圈;我们使用的是 4 像素宽的线条,因此 4 像素半径的圆圈会清晰显示。

触摸移动时的绘图

每当一根或多根手指移动时,都会传递一个 touchmove 事件,从而调用我们的 handleMove() 函数。在此示例中,它的职责是更新缓存的触摸信息,并绘制从每个触摸点的上一个位置到当前位置的线条。

js
function handleMove(evt) {
  evt.preventDefault();
  const el = document.getElementById("canvas");
  const ctx = el.getContext("2d");
  const touches = evt.changedTouches;

  for (const touch of touches) {
    const color = colorForTouch(touch);
    const idx = ongoingTouchIndexById(touch.identifier);

    if (idx >= 0) {
      log(`continuing touch ${idx}`);
      ctx.beginPath();
      log(
        `ctx.moveTo( ${ongoingTouches[idx].pageX}, ${ongoingTouches[idx].pageY} );`,
      );
      ctx.moveTo(ongoingTouches[idx].pageX, ongoingTouches[idx].pageY);
      log(`ctx.lineTo( ${touch.pageX}, ${touch.pageY} );`);
      ctx.lineTo(touch.pageX, touch.pageY);
      ctx.lineWidth = 4;
      ctx.strokeStyle = color;
      ctx.stroke();

      ongoingTouches.splice(idx, 1, copyTouch(touch)); // swap in the new touch record
    } else {
      log("can't figure out which touch to continue");
    }
  }
}

这同样会遍历已更改的触摸,但它会查看我们缓存的触摸信息数组中关于每个触摸点的上一个信息,以确定要绘制的每个触摸点的新线段的起点。这是通过查看每个触摸点的 Touch.identifier 属性来完成的。此属性是每个触摸点的唯一整数,在每次事件中,在手指接触表面的整个过程中都保持一致。

这使我们能够获取每个触摸点上一个位置的坐标,并使用适当的上下文方法绘制连接这两个位置的线段。

绘制线条后,我们调用 Array.splice() 来用 ongoingTouches 数组中的当前信息替换关于该触摸点的上一个信息。

处理触摸结束

当用户抬起手指离开表面时,会发送一个 touchend 事件。我们通过调用下面的 handleEnd() 函数来处理。它的工作是绘制每个结束触摸的最后一条线段,并从正在进行的触摸列表中删除该触摸点。

js
function handleEnd(evt) {
  evt.preventDefault();
  log("touchend");
  const el = document.getElementById("canvas");
  const ctx = el.getContext("2d");
  const touches = evt.changedTouches;

  for (const touch of touches) {
    const color = colorForTouch(touch);
    let idx = ongoingTouchIndexById(touch.identifier);

    if (idx >= 0) {
      ctx.lineWidth = 4;
      ctx.fillStyle = color;
      ctx.beginPath();
      ctx.moveTo(ongoingTouches[idx].pageX, ongoingTouches[idx].pageY);
      ctx.lineTo(touch.pageX, touch.pageY);
      ctx.fillRect(touch.pageX - 4, touch.pageY - 4, 8, 8); // and a square at the end
      ongoingTouches.splice(idx, 1); // remove it; we're done
    } else {
      log("can't figure out which touch to end");
    }
  }
}

这与上一个函数非常相似;唯一的真正区别是,我们绘制一个小正方形来标记结束,并且当我们调用 Array.splice() 时,我们会从正在进行的触摸列表中删除旧条目,而不添加更新后的信息。结果是我们停止跟踪该触摸点。

处理取消的触摸

如果用户的手指意外移入浏览器 UI,或者触摸需要被取消,则会发送 touchcancel 事件,我们调用下面的 handleCancel() 函数。

js
function handleCancel(evt) {
  evt.preventDefault();
  log("touchcancel.");
  const touches = evt.changedTouches;

  for (const touch of touches) {
    let idx = ongoingTouchIndexById(touches[i].identifier);
    ongoingTouches.splice(idx, 1); // remove it; we're done
  }
}

由于目的是立即中止触摸,因此我们将其从正在进行的触摸列表中删除,而不绘制最后的线段。

便利函数

此示例使用了两个便利函数,应简要查看它们,以帮助使其余代码更清晰。

为每个触摸选择颜色

为了使每个触摸点的绘图看起来不同,colorForTouch() 函数用于根据触摸点的唯一标识符选择颜色。此标识符是一个不透明的数字,但我们可以依赖它来区分当前活动的触摸点。

js
function colorForTouch(touch) {
  let r = touch.identifier % 16;
  let g = Math.floor(touch.identifier / 3) % 16;
  let b = Math.floor(touch.identifier / 7) % 16;
  r = r.toString(16); // make it a hex digit
  g = g.toString(16); // make it a hex digit
  b = b.toString(16); // make it a hex digit
  const color = `#${r}${g}${b}`;
  return color;
}

此函数的返回值为一个字符串,可用于调用 <canvas> 函数设置绘图颜色。例如,对于 Touch.identifier 值为 10,结果字符串为“#aa3311”。

复制触摸对象

一些浏览器(例如移动版 Safari)会在事件之间重用触摸对象,因此最好复制您关心的属性,而不是引用整个对象。

js
function copyTouch({ identifier, pageX, pageY }) {
  return { identifier, pageX, pageY };
}

查找正在进行的触摸

下面的 ongoingTouchIndexById() 函数会扫描 ongoingTouches 数组以查找与给定标识符匹配的触摸,然后返回该触摸在数组中的索引。

js
function ongoingTouchIndexById(idToFind) {
  for (let i = 0; i < ongoingTouches.length; i++) {
    const id = ongoingTouches[i].identifier;

    if (id === idToFind) {
      return i;
    }
  }
  return -1; // not found
}

显示正在发生的事情

js
function log(msg) {
  const container = document.getElementById("log");
  container.textContent = `${msg} \n${container.textContent}`;
}

结果

您可以通过触摸下面的框在移动设备上测试此示例。

注意: 更普遍地说,该示例将在提供触摸事件的平台上运行。您可以在可以模拟此类事件的桌面平台上进行测试。

附加提示

本节提供了有关如何在 Web 应用程序中处理触摸事件的其他提示。

处理点击

由于在 touchstart 或一系列 touchmove 事件的第一个事件上调用 preventDefault() 会阻止相应的鼠标事件触发,因此通常会在 touchmove 而不是 touchstart 上调用 preventDefault()。这样,鼠标事件仍然可以触发,链接等内容也可以正常工作。或者,一些框架为了同样的目的,已开始重新触发触摸事件作为鼠标事件。(本示例过于简化,可能导致奇怪的行为。它仅作为指南。)

js
function onTouch(evt) {
  evt.preventDefault();
  if (
    evt.touches.length > 1 ||
    (evt.type === "touchend" && evt.touches.length > 0)
  )
    return;

  const newEvt = document.createEvent("MouseEvents");
  let type = null;
  let touch = null;

  switch (evt.type) {
    case "touchstart":
      type = "mousedown";
      touch = evt.changedTouches[0];
      break;
    case "touchmove":
      type = "mousemove";
      touch = evt.changedTouches[0];
      break;
    case "touchend":
      type = "mouseup";
      touch = evt.changedTouches[0];
      break;
  }

  newEvt.initMouseEvent(
    type,
    true,
    true,
    evt.originalTarget.ownerDocument.defaultView,
    0,
    touch.screenX,
    touch.screenY,
    touch.clientX,
    touch.clientY,
    evt.ctrlKey,
    evt.altKey,
    evt.shiftKey,
    evt.metaKey,
    0,
    null,
  );
  evt.originalTarget.dispatchEvent(newEvt);
}

仅在第二次触摸时调用 preventDefault()

一种防止页面出现 pinchZoom 等行为的技术是在一系列触摸中的第二次触摸时调用 preventDefault()。这种行为在触摸事件规范中没有明确定义,并且会导致不同浏览器出现不同的行为(例如,iOS 会阻止缩放但仍允许双指平移;Android 会允许缩放但阻止平移;Opera 和 Firefox 目前会阻止所有平移和缩放)。目前,不建议在这种情况下依赖任何特定行为,而应依赖 meta viewport 来阻止缩放。

规范

规范
触摸事件

浏览器兼容性

触摸事件通常在具有触摸屏的设备上可用,但许多浏览器在所有桌面设备上都禁用了触摸事件 API,即使是那些带有触摸屏的设备。

这样做的原因是,一些网站将触摸事件 API 部分可用性作为浏览器在移动设备上运行的指示器。如果触摸事件 API 可用,这些网站将假定是移动设备,并提供移动优化内容。这可能会为具有触摸屏的桌面设备用户提供糟糕的体验。

为了支持所有类型的设备上的触摸和鼠标,请改用 指针事件

api.Touch

api.TouchEvent

api.TouchList