在移动设备中,几乎所有的操作都和触摸有关,而基于这一特性,近年来在移动端中也兴起了一些较为特别的交互方式——手势操作。本文将聚焦于 Web 页面中的各种手势操作。

手势

移动端上用户界面的完整性是建立在手势的基础上,手势的应用是在移动设备上的重要用户体验。由于移动端产品屏幕相对来说都比较有限,所以交互设计师需要不断的保证最有效和最便利的手势得以应用。
为了合理运用手势,首先我们需要了解什么是手势,手势的定义是什么?

手势是指人类用语言中枢建立起来的一套用手掌和手指位置、形状的特定语言系统。手持设备中的手势是指,将一系列多点触摸事件综合成为一个单独的事件。我们具体来分析会发现手势其实分为两个状态:接触力和随后的接触活动。

触摸力是用户的手指在屏幕上按压时产生的。例如,如果你在菜单项上点击了一个最基本的手势,你就有一个触摸的操作。
接触活动是针对接触力后产生的结果。例如,你对一张图片进行双击,图片就会放大内容。
接触的活动可能会和不同的接触力组合,产生不同效果。

手势的目的:

  1. 提供真正的便利,而非多一种操作方式
  2. 提供有效反馈
  3. 不增加认知负担和选择成本(为用户提供一个较好的方案即可,尽量避免提供一些类似的操作让用户去选择。因为有多个选择时,会带来认知负担和判断成本,即使选择的过程很短暂。)

移动端的常用手势

手势

不同的手势对应页面上的不同功能,要符合用户的日常认知或是习惯,如果手势含有特殊功能,要在用户操作前给予一定手势操作的引导, 例如:

手势引导

总体来说,如果从方向上来分,手势可是大致分为横滑、竖滑、点击、拖动、双指收缩、拉开等:

  • 横滑:删除、平级切换、返回首页,开关,滑块;
  • 竖滑:下拉刷新,底部加载更多、全屏、上下篇切换;
  • 点击:打开、详情;
  • 拖动:更换位置、拉出其他隐藏内容;
  • 双指收缩、拉开:图片、字体放大;

在了解手势和常见手势后,我们来重点学习浏览器是如何监听手势并进行合理区分的。

手势事件

在浏览器中,为我们提供的手势并不算多,主要有:

  • touchstart 当手指触摸屏幕时触发
  • touchmove 当手指在屏幕滑动时不断的触发
  • touchend 当手指从屏幕上移开时触发
  • touchcancel 当系统停止跟踪触摸时触发

利用以上四个触摸事件,浏览器是如何实现支持众多的交互效果呢,我们从简单的单点触控来看,首先从简单的tap(轻触)来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//自定义一个事件
document.body.addEventListener("tap", function(event) {
console.log("tap事件触发")
}, false)

//触发自定义事件
function fireEvent(element, type, extra) {
var event = doc.createEvent('HTMLEvents');
event.initEvent(type, true, true);
if (typeof extra === 'object') {
//浅拷贝 操作
}
element.dispatchEvent(event);
}

fireEvent(document.body, "tap", {}); //触发tap事件

我们在整个事件模拟中定义一个中间状态 evet.status 来表示当前的触摸状态,接下来我们就可以利用touchstart,touchmove,touchend来实现自己的触摸事件了。

tap事件:当touchstart触发时,我们将event.status状态改为 tapping。在touchend触发时,如果event.status依然为tapping则,触发tap事件。

doubletap事件:在触发tap事件的时候,我们用一个变量lastTime记录当前时间。下一次触发tap时,用当前时间和lastTime做对比,如果小于300ms则触发doubletap事件。

pess事件:当touchstart触发时,我们定义一个setTimeout的函数(500ms),如果500ms后仍然没有touchend触发,则定时函数将event.staus状态改为pressing。当touchend触发时,检测到状态为pressing则触发press事件。

pan事件:我们在touchmove中检测当前状态是tapping和pressing时,并且手指移动距离大于10px则,触发pan平移事件。这个移动距离用event.touches[0].clientX - lastTouch.clientY 来检测就好(利用lastTouch记录,起始手指的event对象)。

flick事件:这个事件就是”刷~刷”的划过屏幕的交互效果,在touchend时通过pan事件的移动距离和移动事件算出速度(注意是X和Y轴的合速度),如果速度大于0.5,并且整个触摸过程时间小于100ms,则触发flick事件。

是不是很简单的用最原始的浏览器事件就能实现这些内容。
接下来让我们看看两个手指的事件如果实现。

实现多指触控

在实现多指触控的时候,我们需要用到触式设备上特有的 gesture event,这个事件是对touch event的更高层的封装,和touch一样,它同样包括gesturestart,gesturechange, gestureend三个事件回调:

  • gesturestart 当有两根或多根手指放到屏幕上的时候触发
  • gesturechange 当有两根或多根手指在屏幕上,并且有手指移动的时候触发
  • gestureend 当倒数第二根手指提起的时候触发,结束gesture

事件处理函数中会得到一个GestureEvent类型的参数,它包含了手指的scale(两根移动过程中分开的比例 )信息和rotation(两根手指间连线转动的角度)信息。
我们还是先看看当分别将两根手指放到屏幕上的时候,会触发哪些事件吧:

  1. 第一根手指放下,触发touchstart;
  2. 第二根手指放下,触发gesturestart,触发第二根手指的touchstart,并且立即触发gesturechange;
  3. 手指移动,持续触发gesturechange,就像鼠标在屏幕上移动的时候不停触发mousemove一样;
  4. 第二根手指提起,触发gestureend,以后将不会再触发gesturechange,并且触发第二根手指的touchend,同时会触发touchstart(注意,多根手指在屏幕上,提起一根,会刷新一次全局 touch!重新触发第一根手指的touchstart);
  5. 第一根手指提起,触发touchend。

让我们来看一个示例,注意,下面这段代码目前只能在IOS上使用,安卓和IOS对gesture的实现方式存在一些差异,所以此处我们以IOS为例来演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var mydiv = document.getElementById('myDiv');

function gesture(event){
console.log(event)
switch(event.type){
case "gesturestart" :
mydiv.innerHTML = "gesture start (rotation=" + event.rotation + '<br/>scale=' + event.scale + ')';
break;
case "gesturechange" :
mydiv.innerHTML = "gesture change (rotation=" + event.rotation + '<br/>scale=' + event.scale + ')';
break;
case "gestureend" :
mydiv.innerHTML = "gesture end (rotation=" + event.rotation + '<br/>scale' + event.scale + ')';
break;
}
}
document.addEventListener('gesturestart',gesture,false);
document.addEventListener('gesturechange',gesture,false);
document.addEventListener('gestureend',gesture,false);

这段代码能让element随着你的两根手指的运动而转动、伸展,效果如下:

gesture

以上的两个示例都是在原生的基础上实现一些简单的手势事件监听,帮助我们理解手势操作底层的逻辑,但实际开发项目时,面对更加复杂的交互,我们可以借助一些开源的手势库来实现。
比如我们常用的Swiper,Zepto,Hammer.js等,根据项目需求选择合适的手势库,可以帮助你提升开发效率。

暗拍单品页手势实现

暗拍单品页需要实现通过上下滑动切换拍品,左右滑切换拍品和详情页的交互效果(拍品页商品图左滑时先切换图片,当滑到最后一张时切换到详情页)。效果如下:
anon

根据业务场景需求首先我们选择Swiper做基础的商品图切换,商品详情页使用tab切换的形式来实现,上下切换相对来说比较复杂,具体实现思路如下:

  1. 首先展示当前拍品的时候需要获取相邻的前后两个拍品;
  2. 为了保证上下滑动时上下拍品都能直接展示,需要在当前拍品展示时请求上下拍品的基本信息;
  3. 上下两个拍品都存放在已经生成好的DOM元素中,将上一个拍品的层级置为-1,将下一个拍品决定定位top值置为100%:

然后我们上滑时,实际上就是将绝对定位的下面一个DOM图层整体拉到当前展示区域,当下滑时则将下层DOM图层展示出来,滑动时可以设置一些过度动画,让整体交互看起来更加流畅。基于以上的设计思路,我决定使用 react-use-gesture手势库,react-use-gesture提供多个钩子函数,可以将鼠标操作事件和触摸事件绑定到任何组件或视图上,它提供的钩子函数如下:

react-use-gesture hook

此处我们主要使用 useGesture 这个钩子函数,useGesture的配置项包含dragwheelscrollpinchmove等手势操作,并且每个操作都额外包含两个处理函数分别处理手势开始和结束两种状态。并且对 wheelscrollmove添加了防抖(debounced)处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const bind = useGesture(
{
onDrag: state => doSomethingWith(state),
onDragStart: state => doSomethingWith(state),
onDragEnd: state => doSomethingWith(state),
onPinch: state => doSomethingWith(state),
onPinchStart: state => doSomethingWith(state),
onPinchEnd: state => doSomethingWith(state),
onScroll: state => doSomethingWith(state),
onScrollStart: state => doSomethingWith(state),
onScrollEnd: state => doSomethingWith(state),
onMove: state => doSomethingWith(state),
onMoveStart: state => doSomethingWith(state),
onMoveEnd: state => doSomethingWith(state),
onWheel: state => doSomethingWith(state),
onWheelStart: state => doSomethingWith(state),
onWheelEnd: state => doSomethingWith(state),
onHover: state => doSomethingWith(state),
},
config
)
return <div {...bind()} />

在了解了useGesture的以上内容后,让我们具体在项目中实际运用一下,首先引入 useGesture,然后给drag手势操作配置options,其实就是手势操作后需要执行的事件,比如此处的上翻页下翻页等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { useGesture } from "react-use-gesture";

const bind = useGesture({
onDragEnd: ({ down, movement: [mx, my] }) => {
// 获取滑动的横向偏移量和纵向偏移量,当横向偏移量大于纵向偏移量,则执行左右滑动的处 理函数,反之则执行上下滑动的处理函数
if (Math.abs(mx) > Math.abs(my)) {
// 向右滑动无操作
if (mx > 0) return;
// 向左滑动
props.parentInstance.handleGestureSwitchFromXAxis(mx);
const xAxisPecent = Math.ceil((mx / window.innerWidth) * 100);
if (xAxisPecent <= -5) {
props.parentInstance.switchToDetail();
props.parentInstance.resetXYAxis();
} else {
props.parentInstance.resetXYAxis();
}
} else {
// 上下切换
props.parentInstance.handlerGestureEndSwitchFromYAxisAnimate(my);
props.parentInstance.resetXYAxis();
}
},
});

之后将bing()函数绑定到需要手势操作的dom元素上即可。基本的手势操作有了后我们可以优化一下,首先左右滑动为防止误操作,可以对滑动距离做一个筛选,只有大于一定距离才触发左滑操作。

1
2
3
4
5
const xAxisPecent = Math.ceil((xAxisDistance / window.innerWidth) * 100);
/** 横滑距离大于屏幕 20% 即切换tab */
if (xAxisPecent <= -15) {
this.refTab.goToTab(1);
}

后面我发现在上下滑动的时候有时候会出现当前拍品会卡在页面中间的情况,是因为上次滑动transform没有还原到初始位置,所以在每次滑动之后可以执行一个重置transform的函数,保证每次滑动后当前拍品模块都回到了初始位置。

1
2
3
4
5
resetXYAxis = () => {
document.getElementById(
"currentProduct"
)!.style.transform = `translate3d(0%, 0, 0)`;
};

代码里面的滑动后的操作包含部分业务逻辑,此处就不做过多的描述了。

总结

从桌面端到移动端,从click到touch、gesture,我们必须要有一个清醒的认识,即要给予用户最好的使用体验,则必须建立在深入了解用户使用情境的基础之上,同时根据文化约定、实物隐喻、表音、表意等方向创新手势交互。

最后,希望这篇文章对大家处理移动端中的手势操作有所帮助!

参考

触摸事件
use-gesture
HTML5移动端交互——手势操作篇