为网站添加触摸功能

从手机到桌面设备的屏幕,使用触摸屏的设备越来越多。应用应以直观而又优雅的方式响应触摸动作。

Matt Gaunt

从手机到桌面设备,使用触摸屏的设备越来越多。当用户选择与应用的界面进行交互时,应用应该以直观的方式响应其触摸动作。

响应元素状态

您是否有过这样的经历:触摸或点按网页上的某个元素时怀疑网站是否真的检测到了您的触摸动作?

只需在用户触摸界面元素或与其进行交互时改变元素的颜色,用户就能基本确认网站处于工作状态。这样做不仅能减轻用户的失望感,还能让其觉得网站敏捷并且响应迅速。

DOM 元素可继承下列任何状态:default、focus、hover 和 active。如需针对上述每种状态更改界面,我们需要将样式应用于以下伪类 :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;
}

试试看

说明以不同颜色代表不同按钮状态的图片

在大多数移动浏览器上,系统会在用户点按某个元素后对其应用 hover 和/或 focus 状态。

请认真考虑所设置的样式以及用户完成触摸后会看到的外观。

禁止使用默认浏览器样式

为不同状态添加样式后,您会注意到大多数浏览器在响应用户触摸时实现的是其自己的样式。这主要是因为当移动设备首次发布时,许多网站还没有 :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 属性实现此目的,但要注意的是,如果用户需要选择元素中的文本,在内容上施加这种限制会令其极其恼怒。因此,请务必谨慎使用。

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

实现自定义手势

如果您想到了一个网站自定义交互和手势创意,需要牢记两个主题:

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

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

根据您希望手势具有的功能,您可能希望用户一次只与一个元素进行交互,或者希望他们能同时与多个元素进行交互。

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

文档触摸 GIF 演示

第一个示例允许用户与一个元素进行交互。在这种情况下,您可能希望所有触摸事件都提供给这一个元素,只要手势最初始于元素本身。例如,将手指移动到可滑动元素之外仍可控制元素。

这很有用处,因为它给用户带来了极大的灵活性,但会给用户与 UI 的交互方式施加限制。

元素触摸 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);
}

试试看

处理单元素交互

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

当我们对 DOM 元素调用 setPointerCapture 时,TouchEvents 将在手势开始后对其进行追踪,无论触摸发生在什么位置;PointerEvents 将追踪事件,无论触摸发生在什么位置。

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

实现以上操作的步骤如下:

  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 停用除“punch-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
按钮处于按下状态
当光标放置于某个元素上面时进入该状态。 悬停时的界面中的变化有助于鼓励用户与元素互动。
:focus
具有焦点状态的按钮
当用户按 Tab 在页面上的各个元素间导航时进入该状态。焦点状态可让用户了解他们当前正在与哪个元素互动;还允许用户使用键盘轻松浏览您的界面。
:active
按钮处于按下状态
当选定某个元素时(例如,当用户正点击或触摸某个元素时)进入该状态。

可以在这里找到权威的触摸事件参考资料:W3C 触摸事件

触摸、鼠标和指针事件

这些事件是为应用新增手势的构建基块:

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

触摸列表

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

触摸事件属性
touches 屏幕上的所有当前触摸列表,无论正在触摸的是哪些元素。
targetTouches 在作为当前事件目标的元素上开始的触摸列表。例如,如果您绑定到 <button>,您将只获取该按钮上的当前触摸。如果您绑定到文档,将获取当前文档上的所有触点。
changedTouches 因发生更改而导致事件触发的触摸列表:
  • 对于 touchstart 事件 - 刚通过当前事件变为有效的接触点列表。
  • 对于 touchmove 事件 - 最后一个事件后发生过移动的触摸点列表。
  • 对于 touchend touchcancel 事件,刚从 Surface 上移除的触摸点的列表。

在 iOS 上启用 active 状态支持

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

此操作应在 User Agent 测试之后进行,这样它就只能运行在 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);
    }
  }
};