序言
在移动端开发中,手势操作非常常见,本篇文章主要讲解常见的 9 种手势操作原理,期间会穿插一些数学知识,将数学运用到实际问题中,数学部分可能会比较枯燥,但希望大家坚持读完,相信会收益良多。
- 点按:
tap
- 长按:
longTap
- 双击:
doubleTap
- 双指缩放:
pinch
- 双指旋转:
rotate
- 单指缩放:
singlePinch
- 单指旋转:
singleRotate
- 滑动:
swipe
- 拖拽:
drag
原理分析
所有的手势操作都是基于浏览器原生事件touchstart
, touchmove
, touchend
, touchcancel
进行上层封装。
TouchEvent 对象上有以下几个属性值,在封装手势库时会用到
touches
当前屏幕上的手指列表targetTouches
当前元素上的手指列表changedTouches
触发当前事件的手指列表- clientX 和 clientY 手指相对于可视区的一个坐标
- pageX 和 pageY 手指相对于页面的一个坐标
点按
为什需要封装tap
事件,而不用clcik
事件?
因为click
事件在移动端会有 300ms 延迟,在早期由于移动端会有双击缩放的这个操作,因此浏览器在 click 之后要等待 300ms,看用户有没有下一次点击,判断这次操作是不是双击。
为什么不用touchstart
或touchend
做点按操作?
因为touchstart
或touchend
在部分 android 机下会造成滑屏误触(在做滑动操作时touchmove
会触发touchend
事件)。
所以需要自定义tap
事件。
原理:在点击时,记录手指坐标。抬起时,判断手指坐标和摁下的手指坐标的差值,这个差值,小于一定值时我们就认定它是点击。也就是以start
时手指的坐标画一个单位圆,如果end
时手指的坐标在此单位圆中,说明是点击操作)
function tap(el, fn) {
let startPoint = {};
el.addEventListener('touchstart', function (e) {
startPoint = {
x: e.changedTouches[0].pageX,
y: e.changedTouches[0].pageY
}
});
el.addEventListener('touchend', function (e) {
let nowPoint = {
x: e.changedTouches[0].pageX,
y: e.changedTouches[0].pageY
}
if (Math.abs(nowPoint.x - startPoint.x) < 10
&& Math.abs(nowPoint.y - startPoint.y) < 10) {
fn && fn.call(el, e);
}
});
长按
原理:touchstart 时开启一个750毫秒
的定时器,如果 750ms 内有 touchmove 或者 touchend 都会清除掉该定时器。超过 750ms 没有 touchmove 或者 touchend 就会触发 longTap
function langTap(el, fn) {
let longTapTimeout = null
el.addEventListener('touchstart', function (e) {
e.preventDefault()
//手指数量
if (e.touches.length == 1) {
longTapTimeout = setTimeout(() => {
fn && fn.call(el, e)
}, 750)
}
})
el.addEventListener('touchmove', function (e) {
clearInterval(longTapTimeout)
})
el.addEventListener('touchend', function (e) {
clearInterval(longTapTimeout)
});
}
双击
原理:在touchstart
中判断两次点击的时间间隔0<time<250ms
并且判断两次按下的手指坐标的差值是否小于某个定值(此逻辑和 tap 事件一样)。如果都满足,那么就在touchend
事件中触发双击。
function doubleTap(el, fn) {
let last, prePoint = { X: 0, Y: 0 }, isDoubleTap = false
el.addEventListener('touchstart', function (e) {
e.preventDefault()
let now = Date.now()
let time = now - (last || now)
let currentPoint = {
X: e.touches[0].pageX,
Y: e.touches[0].pageY
}
// 判断时间差和坐标位置是否小于某个定值
isDoubleTap = time > 0 && time < 250 && Math.abs(currentPoint.X - prePoint.X) < 30 && Math.abs(currentPoint.X - prePoint.Y < 30)
last = Date.now()
prePoint.X = currentPoint.X
prePoint.Y = currentPoint.Y
});
el.addEventListener('touchend', function (e) {
if (isDoubleTap) {
// 重置状态
prePoint = { X: 0, Y: 0 }
isDoubleTap = false
fn && fn.call(el, e)
}
});
}
双指缩放
原理:在捏的过程中求两点之间的距离比值,就是缩放scale
。
这个 scale
会挂载在 event 上,让用户反馈给 dom 的 transform 或者其他元素的 scale 属性
用勾股定理求两点之间距离
勾股定理
已知 A
,B
两点的坐标(x1,y1),(x2,y2),即可根据勾股定理求出c
边的长度
用代码表示
Math.sqrt((x2-x1)*(x2-x1)+(y2-y1)*(y2-y1))
完整代码:
// 计算手指距离
function getLen(v) {
return Math.sqrt(v.x * v.x + v.y * v.y)
}
function pinch(el, fn) {
let preV = { x: null, y: null }
el.addEventListener('touchstart', function (e) {
if (e.touches.length > 1) {
preV = { x: e.touches[1].pageX - e.touches[0].pageX, y: e.touches[1].pageY - e.touches[0].pageY }
}
})
el.addEventListener('touchmove', function (e) {
e.preventDefault()
if (e.touches.length > 1) {
v = { x: e.touches[1].pageX - e.touches[0].pageX, y: e.touches[1].pageY - e.touches[0].pageY }
if (preV.x !== null) {
// 距离比值
e.scale = getLen(v) / getLen(preV)
fn && fn.call(el, e)
}
}
})
}
let box = document.getElementById('box')
pinch(box, e => {
box.innerHTML = e.scale
})
双指旋转
原理:双指旋转也就是求两次手势状态之间的夹角θ
,和旋转方向。
那怎么求夹角和旋转方向呢?可以用向量的数量积和叉乘来求夹角和方向。
先复习一下向量相关的知识。
向量的基本概念
向量:既有大小又有方向的量叫向量,记作:|| 或 a
单位向量: 长度为 1 的向量叫做单位向量
向量的模: 是一个标量,只有大小,没有方向,可用勾股定理求出,记为|a|,
向量的坐标运算
加法运算:若a=(x1,y1)
,b=(x2,y2)
,则a+b=(x1+x2,y1-y2)
减法运算:若a=(x1,y1)
,b=(x2,y2)
,则a-b=(x1-x2,y1-y2)
数乘运算:若a=(x1,y1)
,b=(x2,y2)
,则 λa=(λx1,λy1)
向量坐标的求法:若a=(x1,y1)
,b=(x2,y2)
,则||=(x2-x1,y2-y1)
即一个向量的坐标等于此向量的有向线段的终点坐标减去始点坐标
获取向量的函数:
/**
@params {Object} 始点坐标A
@params {Object} 终点坐标B
@returns {Object} 向量:{x,y}
*/
function getVector(A, B) {
return { x: B.x - A.x, y: B.y - A.y }
}
向量相乘
两个向量相乘得到的不是一个坐标,而是一个确定的数:a*b=x1*x2+y1*y2
向量的数乘(叉乘)
概念:一般的,规定实数λ
与向量a
的积是一个向量,这种运算叫做向量的数乘,记作λa
,它的长度与方向规定如下:
- |λa|=λ|a|
- 当 λ>0 时,λa 的方向与 a 的
方向相同
- 当 λ<0 时,λa 的方向与 a 的方向
方向相反
向量共线定理
概念:当且仅当有唯一一个实数λ
,是b=λa
,那么向量a
与b
共线
向量共线的坐标推导
- 当
x1·y2-x2·y1>0
,b 向量相对于 a 向量顺时针旋转 - 当
x1·y2-x2·y1<0
,b 向量相对于 a 向量逆时针旋转 - 当
x1·y2-x2·y1=0
,共线
通过共线定理
我们可以判断出旋转的方向
向量的数量积(内积)
概念:已知两个非零向量a
,b
,a=(x1,y1)
,b=(x2,y2)
。我们把数量|a||b|·cosθ
叫做a
与b
的数量积(或内积),记作a*b
,即a·b=|a|·|b|·cosθ=x1*x2+y1*y2
,其中θ
是a
与b
的夹角
数量积可根据三角形的余弦定理推导出来:
由此我们可以得出
cosθ=(x1·x2+y1·y2)/(|a|·|b|)
通过向量的数量积我们可以求出旋转的角度。
完整代码为:
//根据共线定理判断方向
function cross(v1, v2) {
return v1.x * v2.y - v2.x * v1.y
}
// 勾股定理计算长度
function getLen(v) {
return Math.sqrt(v.x * v.x + v.y * v.y)
}
// 计算向量积
function dot(v1, v2) {
return v1.x * v2.x + v1.y * v2.y;
}
// 计算弧度
function getAngle(v1, v2) {
let mr = getLen(v1) * getLen(v2)
if (mr === 0) return 0
let r = dot(v1, v2) / mr //得到弧度
if (r > 1) r = 1 // Math.acos(1)=0
return Math.acos(r)
}
// 传入两个向量
function getRotateAngle(v1, v2) {
let angle = getAngle(v1, v2)
if (cross(v1, v2) > 0) {
angle *= -1
}
return angle * 180 / Math.PI //弧度转角度
}
function rotate(el, fn) {
let preV = { x: null, y: null }
el.addEventListener('touchmove', function (e) {
if (e.touches.length > 1) {
let currentX = e.touches[0].pageX,
currentY = e.touches[0].pageY,
// 计算向量
v = { x: e.touches[1].pageX - currentX, y: e.touches[1].pageY - currentY }
// 拿到旋转角度 因为每次计算的旋转角度是上一次和当前旋转的差值,所以的到的旋转角度会比较小,注意与Math.atan2()区分
e.angle = getRotateAngle(v, preV)
if (preV.x !== null) {
fn && fn.call(el, e)
}
preV.x = v.x
preV.y = v.y
}
})
}
let box = document.getElementById('box')
rotate(box, e => {
box.innerHTML = e.angle
})
单指缩放和单指旋转都需要依赖于操作元素的基准点(操作元素的中心点)进行计算
单指缩放
由 a 向量单指放大到 b 向量,对元素进行了中心放大,此时缩放值即为 b 向量的模 / a 向量的模。和缩放手势原理相同。
单指旋转
和双指旋转一样,θ
就是我们要求的角度
单指缩放,单指旋转,多用于处理图片场景当中,比如说在 canvas 画布当中给图片添加水印或文字。
滑动(swipe)
滑动这个操作很有意思,它正好和 点按
手势相反,需要当 touchstart 的手的坐标和 touchend 时候手的坐标 x、y 方向偏移要大于 30,然后再去判断用户到底是从上到下,还是从下到上,或者从左到右、从右到左滑动。
比较横纵坐标的绝对值,然后再根据某以方向的坐标判断出上下滑动还是左右滑动
Math.abs(x1 - x2) >= Math.abs(y1 - y2) ? (x1 - x2 > 0 ? 'Left' : 'Right') : (y1 - y2 > 0 ? 'Up' : 'Down')
完整代码:
function swipeDirection(x1, x2, y1, y2) {
return Math.abs(x1 - x2) >= Math.abs(y1 - y2) ? (x1 - x2 > 0 ? 'Left' : 'Right') : (y1 - y2 > 0 ? 'Up' : 'Down')
}
function swipe(el, fn) {
let startPoint = {};
el.addEventListener('touchstart', function (e) {
e.preventDefault();
startPoint = {
x: e.changedTouches[0].pageX,
y: e.changedTouches[0].pageY
}
});
el.addEventListener('touchend', function (e) {
let nowPoint = {
x: e.changedTouches[0].pageX,
y: e.changedTouches[0].pageY
}
if (Math.abs(nowPoint.x - startPoint.x) > 10 && Math.abs(nowPoint.y - startPoint.y) > 10) {
e.direction = swipeDirection(startPoint.x, nowPoint.x, startPoint.y, nowPoint.y);
fn && fn.call(el, e);
}
});
}
let box = document.getElementById('box')
swipe(box, e => {
box.innerHTML = e.direction
})
拖拽
在touchmove
把每次移动的距离deltaX
,deltaY
,挂载在 event
上。拿到移动距离+=
就能实现一个简单的拖拽
div {
width: 200px;
height: 200px;
border: 1px solid sienna;
background: saddlebrown;
}
<div id="box">拖拽此物体</div>
function drag(el, fn) {
let x2 = null, y2 = null
el.addEventListener('touchmove', function (e) {
e.preventDefault()
let currentX = e.touches[0].pageX, currentY = e.touches[0].pageY
if (x2 !== null || y2 !== null) {
e.deltaX = currentX - x2
e.deltaY = currentY - y2
fn && fn.call(el, e)
}
x2 = currentX
y2 = currentY
})
el.addEventListener('touchend', function (e) {
x2 = null
y2 = null
})
}
let box = document.getElementById('box')
box.style.transform = `translate3d(0,0,0)`
drag(box, e => {
let translates = getComputedStyle(box, null).transform
let x = parseFloat(translates.substring(6).split(',')[4]) //解析x轴数值
let y = parseFloat(translates.substring(6).split(',')[5]); //解析y轴数值
box.style.transform = `translate3d(${x += e.deltaX}px,${y += e.deltaY}px,0)`
})
同时支持触摸事件和鼠标事件
虽然说触摸事件和鼠标事件很相似,不过二者仍然需要分开处理。假如想让应用程序同时运行在桌面浏览器与手机浏览器之中,那么必须将触摸事件于鼠标事件同等对待。把事件处理逻辑封装在同一系列方法当中。这些方法不需要知道到底是触摸事件还是鼠标事件。
ul {
width: 200px;
height: 361px;
cursor: pointer;
position: absolute;
top: 0px;
left: 0px;
background: #787878;
}
<ul id="ul1">拖拽 </ul>
let oUl = document.getElementById('ul1')
let disX = 0;
let offsetLeft = 0
oUl.onmousedown = function (ev) {
mouseDownOrTouchStart(ev.pageX)
oUl.onmousemove = function (ev) {
mouseMoveOrTouchMove(ev.pageX)
}
oUl.onmouseup = function (ev) {
oUl.onmousemove = null
oUl.onmouseup = null
}
}
oUl.ontouchstart = function (ev) {
mouseDownOrTouchStart(ev.touches[0].pageX)
}
oUl.ontouchmove = function (ev) {
mouseMoveOrTouchMove(ev.touches[0].pageX)
}
// 代理
function mouseDownOrTouchStart(pageX) {
disX = pageX
offsetLeft = oUl.offsetLeft
}
function mouseMoveOrTouchMove(pageX) {
oUl.style.left = pageX - disX + offsetLeft + 'px'
}
以上demo都放到github上啦,感兴趣的可以加个star~ 😊