为网站添加触摸功能

触摸屏适用于越来越多的设备,从手机到桌面设备屏幕,不一而足。您的应用应以直观而精美的方式响应触摸事件。

Matt Gaunt

触摸屏适用于越来越多的设备,从手机到桌面设备屏幕。当用户选择与界面交互时,您的应用应以直观的方式响应他们的轻触操作。

响应元素状态

您是否曾经触摸或点击过网页上的某个元素,并怀疑网站是否真的检测到了该元素?

只需在用户触摸界面的各个部分或与这些部分互动时改变元素的颜色,就能基本确定您的网站在正常运行。这不仅能减轻用户的失望感,还能让其觉得网站敏捷且响应迅速。

DOM 元素可以继承以下任一状态:默认、焦点、悬停和活跃。如需针对上述每种状态更改界面,我们需要将样式应用于以下伪类 :hover:focus:active,如下所示:

.btn {
  background-color: #4285f4;
}

.btn:hover {
  background-color: #296cdb;
}

.btn:focus {
  background-color: #0f52c1;

  /* The outline parameter suppresses the border
  color / outline when focused */
  outline: 0;
}

.btn:active {
  background-color: #0039a8;
}

试试看

该图片展示了按钮状态的不同颜色

在大多数移动浏览器中,系统会在用户点按某个元素后对其应用“悬停”和/或“聚焦”状态。hoverhover

请仔细考虑您设置的样式以及用户在完成轻触后看到的样式。

禁止显示默认浏览器样式

为不同状态添加样式后,您会注意到,大多数浏览器都会实现自己的样式来响应用户的触摸。这主要是因为当移动设备首次发布时,许多网站没有针对 :active 状态的样式。因此,许多浏览器添加了额外的突出显示颜色或样式来向用户提供反馈。

大多数浏览器使用 outline CSS 属性在某个元素获得焦点时在其周围显示圆环。您可以通过以下方式将其抑制:

.btn:focus {
    outline: 0;

    /* Add replacement focus styling here (i.e. border) */
}

Safari 和 Chrome 添加了点按突出显示颜色,可使用 -webkit-tap-highlight-color CSS 属性阻止:

/* Webkit / Chrome Specific CSS to remove tap
highlight color */
.btn {
  -webkit-tap-highlight-color: transparent;
}

试试看

Windows Phone 上的 Internet Explorer 也有类似行为,但可通过元标记禁止:

<meta name="msapplication-tap-highlight" content="no">

Firefox 有两个副作用需要处理。

-moz-focus-inner 伪类,它会在可触摸元素上添加轮廓,可通过设置 border: 0 将其移除。

如果您在 Firefox 中使用 <button> 元素,则会应用渐变效果,可以通过设置 background-image: none 将其移除。

/* Firefox Specific CSS to remove button
differences and focus ring */
.btn {
  background-image: none;
}

.btn::-moz-focus-inner {
  border: 0;
}

试试看

停用用户选择

在创建界面时,在某些情况下,您可能希望用户与元素互动,但又希望禁止在长按界面或将鼠标指针悬停在界面上时选择文本的默认行为。

您可以使用 user-select CSS 属性执行此操作,但要注意,如果用户想要选择元素中的文字,对内容进行此操作可能会extremely让用户感到不快。 因此请务必谨慎使用。

/* Example: Disable selecting text on a paragraph element: */
p.disable-text-selection {
  user-select: none;
}

实现自定义手势

如果您想知道如何让网站进行自定义互动和手势,请记住以下两个主题:

  1. 如何支持所有浏览器。
  2. 如何保持较高的帧速率。

在本文中,我们关注的正是这些主题,它们先是介绍覆盖所有浏览器所需支持的 API,然后介绍如何高效地使用这些事件。

根据您希望手势执行的操作,您可能希望用户一次与一个元素互动,希望他们能够同时与多个元素互动。

在本文中,我们将介绍两个示例,它们都展示了如何支持所有浏览器以及如何保持较高的帧速率。

文档触摸 GIF 示例

第一个示例可让用户与一个元素互动。在这种情况下,您可能希望将所有触摸事件都提供给该元素,只要手势最初是从元素本身开始的。例如,将手指从可滑动的元素上移开仍然可以控制该元素。

这非常有用,因为它为用户提供了极大的灵活性,但会对用户与界面的互动方式施加限制。

元素触摸 GIF 示例

不过,如果您希望用户同时与多个元素互动(使用多点触控),则应仅限触摸特定元素。

这对用户而言更加灵活,但会使操纵界面的逻辑复杂化,并且对用户错误的适应性更低。

添加事件监听器

在 Chrome(版本 55 及更高版本)、Internet Explorer 和 Edge 中,建议使用 PointerEvents 实现自定义手势。

在其他浏览器中,TouchEventsMouseEvents 是正确的方法。

PointerEvents 的一大功能是,它将多种类型的输入(包括鼠标、触摸和触控笔事件)合并到一组回调中。要监听的事件包括 pointerdownpointermovepointeruppointercancel

在其他浏览器中,等效操作是针对触摸事件的 touchstarttouchmovetouchendtouchcancel;如果要为鼠标输入实现相同的手势,则需要实现 mousedownmousemovemouseup

如果您对要使用的事件有疑问,请查看此触摸、鼠标和指针事件表。

如需使用这些事件,需要对 DOM 元素调用 addEventListener() 方法,并调用事件名称、回调函数和布尔值。此布尔值确定您应该在其他元素有机会捕获并解读事件之前还是之后捕获事件。(true 表示您希望优先显示该事件。)

下面是一个监听互动开始的示例。

// Check if pointer events are supported.
if (window.PointerEvent) {
  // Add Pointer Event Listener
  swipeFrontElement.addEventListener('pointerdown', this.handleGestureStart, true);
  swipeFrontElement.addEventListener('pointermove', this.handleGestureMove, true);
  swipeFrontElement.addEventListener('pointerup', this.handleGestureEnd, true);
  swipeFrontElement.addEventListener('pointercancel', this.handleGestureEnd, true);
} else {
  // Add Touch Listener
  swipeFrontElement.addEventListener('touchstart', this.handleGestureStart, true);
  swipeFrontElement.addEventListener('touchmove', this.handleGestureMove, true);
  swipeFrontElement.addEventListener('touchend', this.handleGestureEnd, true);
  swipeFrontElement.addEventListener('touchcancel', this.handleGestureEnd, true);

  // Add Mouse Listener
  swipeFrontElement.addEventListener('mousedown', this.handleGestureStart, true);
}

试试看

处理单元素交互

在上面的简短代码段中,我们仅添加了鼠标事件的起始事件监听器。这是因为,只有当光标悬停在添加了事件监听器的元素上时,才会触发鼠标事件。

TouchEvents 会在手势启动后跟踪手势,无论轻触发生的位置如何;而 PointerEvents 将跟踪事件,无论在我们对 DOM 元素调用 setPointerCapture 后轻触发生在什么位置。

对于鼠标移动和结束事件,我们在手势开始方法中添加了事件监听器,并向文档添加了监听器,这意味着它可以跟踪光标,直到手势完成为止。

实现此目标的步骤如下:

  1. 添加所有 TouchEvent 和 PointerEvent 监听器。对于 MouseEvents,添加开始事件。
  2. 在开始手势回调内,将鼠标移动和结束事件绑定到文档。这样,所有鼠标事件都会被接收,无论事件是否发生在原始元素上。对于 PointerEvents,我们需要对原始元素调用 setPointerCapture() 才能接收所有进一步的事件。然后处理手势开始时的操作。
  3. 处理移动事件。
  4. 在结束事件中,从文档中移除鼠标移动和结束监听器,然后结束手势。

以下是 handleGestureStart() 方法的代码段,它会向文档添加移动和结束事件:

// Handle the start of gestures
this.handleGestureStart = function(evt) {
  evt.preventDefault();

  if(evt.touches && evt.touches.length > 1) {
    return;
  }

  // Add the move and end listeners
  if (window.PointerEvent) {
    evt.target.setPointerCapture(evt.pointerId);
  } else {
    // Add Mouse Listeners
    document.addEventListener('mousemove', this.handleGestureMove, true);
    document.addEventListener('mouseup', this.handleGestureEnd, true);
  }

  initialTouchPos = getGesturePointFromEvent(evt);

  swipeFrontElement.style.transition = 'initial';
}.bind(this);

试试看

我们添加的结束回调是 handleGestureEnd(),它会从文档中移除移动和结束事件监听器,并在手势完成时释放指针捕获,如下所示:

// Handle end gestures
this.handleGestureEnd = function(evt) {
  evt.preventDefault();

  if (evt.touches && evt.touches.length > 0) {
    return;
  }

  rafPending = false;

  // Remove Event Listeners
  if (window.PointerEvent) {
    evt.target.releasePointerCapture(evt.pointerId);
  } else {
    // Remove Mouse Listeners
    document.removeEventListener('mousemove', this.handleGestureMove, true);
    document.removeEventListener('mouseup', this.handleGestureEnd, true);
  }

  updateSwipeRestPosition();

  initialTouchPos = null;
}.bind(this);

试试看

通过遵循向文档添加移动事件的这种模式,如果用户开始与元素互动并将手势移动到元素外部,那么无论鼠标移动位于页面上的什么位置,我们都会继续获得鼠标移动事件,因为事件是从文档接收的。

此图显示了在手势开始后我们向文档添加移动和结束事件时触摸事件的作用。

演示如何在“touchstart”中将触摸事件绑定到文档

高效响应触摸操作

现在,我们已经处理了开始和结束事件,接下来便可以实际响应触摸事件了。

对于任何开始和移动事件,您都可以从事件中轻松提取 xy

以下示例通过检查 targetTouches 是否存在来检查事件是否来自 TouchEvent。如果是,则从第一次轻触中提取 clientXclientY。如果事件是 PointerEventMouseEvent,它会直接从事件本身提取 clientXclientY

function getGesturePointFromEvent(evt) {
    var point = {};

    if (evt.targetTouches) {
      // Prefer Touch Events
      point.x = evt.targetTouches[0].clientX;
      point.y = evt.targetTouches[0].clientY;
    } else {
      // Either Mouse event or Pointer Event
      point.x = evt.clientX;
      point.y = evt.clientY;
    }

    return point;
  }

试试看

TouchEvent 有三个包含触摸数据的列表:

  • touches:屏幕上当前所有轻触的列表,无论它们位于哪个 DOM 元素上。
  • targetTouches:当前在事件所绑定到的 DOM 元素上的触摸列表。
  • changedTouches:因发生更改而导致事件触发的触摸列表。

在大多数情况下,targetTouches 可以满足你的所有需要。(如需详细了解这些列表,请参阅触摸列表)。

使用 requestAnimationFrame

由于事件回调是在主线程上触发的,因此我们希望在事件回调中运行尽可能少的代码,以保持较高的帧速率并防止出现卡顿。

借助 requestAnimationFrame(),我们有机会在浏览器打算绘制帧之前更新界面,这有助于我们从事件回调中移出一些工作。

如果您不熟悉 requestAnimationFrame(),可以点击此处了解详情

典型的实现是保存来自开始和移动事件的 xy 坐标,并在移动事件回调内请求动画帧。

在演示中,我们将初始触摸位置存储在 handleGestureStart() 中(查找 initialTouchPos):

// Handle the start of gestures
this.handleGestureStart = function(evt) {
  evt.preventDefault();

  if (evt.touches && evt.touches.length > 1) {
    return;
  }

  // Add the move and end listeners
  if (window.PointerEvent) {
    evt.target.setPointerCapture(evt.pointerId);
  } else {
    // Add Mouse Listeners
    document.addEventListener('mousemove', this.handleGestureMove, true);
    document.addEventListener('mouseup', this.handleGestureEnd, true);
  }

  initialTouchPos = getGesturePointFromEvent(evt);

  swipeFrontElement.style.transition = 'initial';
}.bind(this);

handleGestureMove() 方法会在请求动画帧之前存储其事件的位置(如果需要),并传入 onAnimFrame() 函数作为回调:

this.handleGestureMove = function (evt) {
  evt.preventDefault();

  if (!initialTouchPos) {
    return;
  }

  lastTouchPos = getGesturePointFromEvent(evt);

  if (rafPending) {
    return;
  }

  rafPending = true;

  window.requestAnimFrame(onAnimFrame);
}.bind(this);

onAnimFrame 值是一个函数,被调用时会更改界面,使其四处移动。通过将此函数传入 requestAnimationFrame(),我们会告知浏览器在即将更新页面(即对页面绘制任何更改)之前调用该函数。

handleGestureMove() 回调中,我们首先检查 rafPending 是否为 false,这表示自上次移动事件以来 requestAnimationFrame() 是否调用了 onAnimFrame()。这意味着,我们一次只有一个 requestAnimationFrame() 在等待运行。

执行 onAnimFrame() 回调时,我们会在想要移动的任何元素上设置转换,然后再将 rafPending 更新为 false,从而允许下一个触摸事件请求新的动画帧。

function onAnimFrame() {
  if (!rafPending) {
    return;
  }

  var differenceInX = initialTouchPos.x - lastTouchPos.x;
  var newXTransform = (currentXPosition - differenceInX)+'px';
  var transformStyle = 'translateX('+newXTransform+')';

  swipeFrontElement.style.webkitTransform = transformStyle;
  swipeFrontElement.style.MozTransform = transformStyle;
  swipeFrontElement.style.msTransform = transformStyle;
  swipeFrontElement.style.transform = transformStyle;

  rafPending = false;
}

使用触摸操作控制手势

借助 CSS 属性 touch-action,您可以控制元素的默认触摸行为。在我们的示例中,我们使用 touch-action: none 来阻止浏览器在用户触摸时执行任何操作,从而拦截所有触摸事件。

/* Pass all touches to javascript: */
button.custom-touch-logic {
  touch-action: none;
}

使用 touch-action: none 是一个重要的选择,因为它会阻止所有默认浏览器行为。在许多情况下,以下选项之一是更好的解决方案。

touch-action 可让您停用浏览器实现的手势。例如,IE10+ 支持点按两次进行缩放手势。将 touch-action 设置为 manipulation 可以阻止默认的点按两次行为。

这样,您就可以自行实现点按两次手势。

下面列出了常用的 touch-action 值:

触摸操作参数
touch-action: none 浏览器不会处理任何触摸交互。
touch-action: pinch-zoom 停用除“pinch-zoom”(仍由浏览器处理)以外的所有浏览器互动,例如“touch-action: none”。
touch-action: pan-y pinch-zoom 在 JavaScript 中处理水平滚动,而不停用垂直滚动或双指张合缩放功能(例如图片轮播界面)。
touch-action: manipulation 停用点按两次手势,以避免浏览器执行任何点击延迟。将滚动和双指张合缩放功能交由浏览器执行。

支持旧版 IE

如果您想支持 IE10,则需要处理添加了供应商前缀的 PointerEvents 版本。

若要检查是否支持 PointerEvents,您通常需要查找 window.PointerEvent,但在 IE10 中,您需要查找 window.navigator.msPointerEnabled

带有供应商前缀的事件名称为:'MSPointerDown''MSPointerUp''MSPointerMove'

以下示例展示了如何检查支持情况和切换事件名称。

var pointerDownName = 'pointerdown';
var pointerUpName = 'pointerup';
var pointerMoveName = 'pointermove';

if (window.navigator.msPointerEnabled) {
  pointerDownName = 'MSPointerDown';
  pointerUpName = 'MSPointerUp';
  pointerMoveName = 'MSPointerMove';
}

// Simple way to check if some form of pointerevents is enabled or not
window.PointerEventsSupport = false;
if (window.PointerEvent || window.navigator.msPointerEnabled) {
  window.PointerEventsSupport = true;
}

如需了解详情,请查看这篇 Microsoft 更新文章

参考

针对触摸状态的伪类

示例 说明
:hover
处于按下状态的按钮
当光标位于元素上方时进入此状态。 悬停时界面的变化有助于鼓励用户与元素互动。
:焦点
处于聚焦状态的按钮
当用户按 Tab 键在页面上的元素间切换时输入。焦点状态可让用户知道他们当前正在与哪个元素互动;还可以让用户使用键盘轻松浏览您的界面。
:有效
处于按下状态的按钮
在选择某个元素时(例如,当用户点击或轻触某个元素时)输入该元素。

权威触摸事件参考文档位于:W3C 触摸事件

触摸、鼠标和指针事件

以下事件是向应用添加新手势的构建块:

触摸、鼠标、指针事件
touchstartmousedownpointerdown 当手指首次触摸某个元素或用户点击鼠标时,系统会调用此方法。
touchmovemousemovepointermove 当用户在屏幕上移动手指或使用鼠标拖动时,系统会调用此方法。
touchendmouseuppointerup 当用户将手指从屏幕上抬起或松开鼠标时,系统会调用此方法。
touchcancel pointercancel 当浏览器取消触摸手势时,系统会调用此方法。例如,用户轻触某个 Web 应用,然后更改标签页。

触摸列表

每个触摸事件都包含三个列表属性:

触摸事件属性
touches 屏幕上当前所有轻触操作的列表(不考虑元素轻触操作)。
targetTouches 从当前事件的目标元素上开始的触摸列表。例如,如果绑定到 <button>,则只会获得该按钮上的当前轻触操作。如果绑定到文档,则会获取文档当前的所有轻触操作。
changedTouches 因发生更改而导致事件触发的触摸列表:
  • 对于 touchstart 事件 - 随着当前事件刚刚变为活跃状态的接触点列表。
  • 对于 touchmove 事件 - 自上次事件后移动的接触点列表。
  • 对于 touchend touchcancel 事件 - 刚刚从界面中移除的接触点列表。

在 iOS 上启用活跃状态支持

遗憾的是,iOS 版 Safari 默认不会应用 active 状态。要让它正常运行,您需要向文档正文或每个元素添加一个 touchstart 事件监听器。

您应在用户代理测试之后执行此操作,这样测试就只能在 iOS 设备上运行。

向 body 添加触摸开始的优点是可以应用于 DOM 中的所有元素,但在滚动页面时,这可能会导致性能问题。

window.onload = function() {
  if (/iP(hone|ad)/.test(window.navigator.userAgent)) {
    document.body.addEventListener('touchstart', function() {}, false);
  }
};

另一种方法是将触摸开始监听器添加到页面中的所有可交互元素,从而缓解部分性能问题。

window.onload = function() {
  if (/iP(hone|ad)/.test(window.navigator.userAgent)) {
    var elements = document.querySelectorAll('button');
    var emptyFunction = function() {};

    for (var i = 0; i < elements.length; i++) {
        elements[i].addEventListener('touchstart', emptyFunction, false);
    }
  }
};