fbe05f1fc0733d7e5589def1ee85d8a8.png

序言

在移动端开发中,手势操作非常常见,本篇文章主要讲解常见的 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,看用户有没有下一次点击,判断这次操作是不是双击。

为什么不用touchstarttouchend做点按操作?

因为touchstarttouchend在部分 android 机下会造成滑屏误触(在做滑动操作时touchmove会触发touchend事件)。

所以需要自定义tap事件。

a3a7a6d2c80805987765a92957c77609.png

原理:在点击时,记录手指坐标。抬起时,判断手指坐标和摁下的手指坐标的差值,这个差值,小于一定值时我们就认定它是点击。也就是以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)
        }
    });
}

双指缩放

58aa1fdc32599493cb514731c27b9b26.png

原理:在捏的过程中求两点之间的距离比值,就是缩放scale。 这个 scale 会挂载在 event 上,让用户反馈给 dom 的 transform 或者其他元素的 scale 属性

勾股定理求两点之间距离

勾股定理

已知 A,B两点的坐标(x1,y1),(x2,y2),即可根据勾股定理求出c边的长度

fe6c14402119688f00c5023cf88d1f8d.png

用代码表示

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
       })

双指旋转

cf32f8ede58788a629f701b4bcdc58e1.png

原理:双指旋转也就是求两次手势状态之间的夹角θ,和旋转方向。 那怎么求夹角和旋转方向呢?可以用向量的数量积叉乘来求夹角和方向。 先复习一下向量相关的知识。

向量的基本概念

向量:既有大小又有方向的量叫向量,记作:

equation?tex=%5Cbar%7BAB%7D 或 a

单位向量: 长度为 1 的向量叫做单位向量

向量的模: 是一个标量,只有大小,没有方向,可用勾股定理求出,记为|a|

47e4dbcfbc255d530dd074f2fe61ab97.png

向量的坐标运算

加法运算:若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),则

equation?tex=%5Cbar%7BAB%7D =(x2-x1,y2-y1)
即一个向量的坐标等于此向量的有向线段的 终点坐标减去始点坐标

7db12970f0b8f789e4e4bedd13a98dbe.png

获取向量的函数:

/**
    @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 }
   }

想更深入的了解向量运算可参考这篇文章

线性代数学习点(三):向量相加的几何表示_深入理解数字信号处理-CSDN博客_向量相加​blog.csdn.net
faf863df443edb2bd990a1f6d29a5bfa.png

向量相乘

两个向量相乘得到的不是一个坐标,而是一个确定的数:a*b=x1*x2+y1*y2

向量的数乘(叉乘)

概念:一般的,规定实数λ与向量a的积是一个向量,这种运算叫做向量的数乘,记作λa,它的长度与方向规定如下:

  • |λa|=λ|a|
  • 当 λ>0 时,λa 的方向与 a 的方向相同
  • 当 λ<0 时,λa 的方向与 a 的方向方向相反

向量共线定理

概念:当且仅当有唯一一个实数λ,是b=λa,那么向量ab共线

向量共线的坐标推导

9cc3685279892b039deaa128ac10c5ba.png
  • 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θ叫做ab的数量积(或内积),记作a*b,即a·b=|a|·|b|·cosθ=x1*x2+y1*y2,其中θab的夹角

数量积可根据三角形的余弦定理推导出来:

dd436af3eca8a3912589ee570575d736.png

由此我们可以得出

cosθ=(x1·x2+y1·y2)/(|a|·|b|)

f26384639fe34991eb9c3a23bdf8aa20.png

通过向量的数量积我们可以求出旋转的角度。

完整代码为:

//根据共线定理判断方向
        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
        })

单指缩放单指旋转都需要依赖于操作元素的基准点(操作元素的中心点)进行计算

单指缩放

98fc173be7d9c47c664dfb9148ecb710.png

由 a 向量单指放大到 b 向量,对元素进行了中心放大,此时缩放值即为 b 向量的模 / a 向量的模。和缩放手势原理相同。

单指旋转

a25d1e36cc851d7cf3e236340bf2d085.png

和双指旋转一样,θ就是我们要求的角度

单指缩放,单指旋转,多用于处理图片场景当中,比如说在 canvas 画布当中给图片添加水印或文字。

滑动(swipe)

滑动这个操作很有意思,它正好和 点按手势相反,需要当 touchstart 的手的坐标和 touchend 时候手的坐标 x、y 方向偏移要大于 30,然后再去判断用户到底是从上到下,还是从下到上,或者从左到右、从右到左滑动。

aa725bd54e7b71d59781611a9ba52e72.png

比较横纵坐标的绝对值,然后再根据某以方向的坐标判断出上下滑动还是左右滑动

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~

https://github.com/wensiyuanseven/gesture-dem

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐