ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用PointerEvent状态机实现双指拖拽旋转与缩放

用PointerEvent状态机实现双指拖拽旋转与缩放 简介基于 hammer.js 的手势交互方案面向需要为图片、卡片或画布元素增加多指操作能力的前端开发者解决移动端图片同时拖拽、旋转、缩放时容易出现的操作冲突与抖动。针对官方 rotate demo 中双指触控导致旋转乱跳、复位异常的 bug作者重新实现了旋转角度的计算与更新逻辑并在压缩包中给出可直接运行的 demo修复后既能流畅拖拽与缩放又避免双指切换时旋转跳变。压缩包为 RAR 格式共 4 个文件包含 HTML 演示页、CSS 样式、JS 逻辑文件和一张测试图片整体仅 51KB轻量易读方便对照修改或直接迁移到项目中。该资源已有 5622 人学习下载示例结构简洁直观可帮助快速掌握 hammer.js 的手势组合用法并规避官方示例中的常见坑点。1. 双指手势总打架先把拖拽、旋转、缩放收进同一个状态机做图片预览、户型图缩放、白板画布这类功能时最崩溃的往往不是某个手势单独实现不了而是它们凑到一起就互相干扰双指缩放一执行拖拽就跳一下图片旋转 90 度后再用单指去拖方向怎么都对不上。这套 js 手势操作想要做到“完美”关键不在某个黑匣子 API而在于把拖拽、旋转、放大缩小当作同一个状态机里的三个变量每次指针移动都一次性结算。本文会从 PointerEvent 的指针追踪讲起落到一份可以直接抄进项目的原生 JS 实现以及我在真机上踩过的那些坑。适合正在给图片查看器、地图标注或编辑器做手势交互的前端开发者看完能自己动手复现这套交互。2. 先立住原理为什么“同时”这件事必须靠状态机2.1 拆开监听 touch 事件的老路子为什么做不出“同时”以前做移动端手势大家习惯用 touchstart、touchmove、touchend 来追踪手指。单指拖拽时逻辑很简单算一下 e.touches[0] 的位移差就行。但一旦加上双指缩放和旋转麻烦就来了双指操作时touches 数组里有两根手指拖拽、旋转、缩放三个动作全都混在同一串 move 事件里你很难判断当前这一帧需要更新哪个状态。更麻烦的是浏览器在触摸滚动时还会抢占事件双指一放上去页面就跟着缩放这时候你再去做 translate 和 rotate结果就是图片在天上飞、手指在地上滑整个交互完全失控。所以我现在的做法是彻底抛弃 touch 系列事件统一用 PointerEvent。它把鼠标、触摸、笔尖合成同一套事件模型而且每个按下的触点都有独立的 pointerId。这样状态机就可以用一张 Map 来保存所有在按的指针通过判断 Map 的大小在单指拖拽和双指变换之间做切换。这套设计的核心原则是不要为“拖拽”“旋转”“缩放”分别维护三个逻辑分支而是把最终要写入 CSS transform 的 x、y、scale、rotate 这四个状态放到一个对象里每次 move 事件都同步更新它们。这样无论用户是先移动再旋转还是一边捏合一边挪位置状态都保持一致不会出现“先拖后转”或者“先转后拖”的割裂感。2.2 两个手指能给出三个几何量公式一次算清双指手势的数学基础其实非常直观。取两个触点 P1、P2可以算出三个量两指距离 d用勾股定理计算每次 move 事件里当前距离除以上一次距离得到的比例就是当前这次手指移动带来的缩放增量累乘到 state.scale 上。两指连线与水平方向的夹角 α用Math.atan2(p2.y - p1.y, p2.x - p1.x)计算当前角度减去上一次角度就是旋转增量累加到 state.rotate 上。两指中点 M它的屏幕位移就是这次手指移动想要的图片位移增量累加到 state.x 和 state.y 上。把这三个量做成一张对照表方便直接对照代码计算目标手势输入公式更新方式缩放倍数两指距离 dMath.hypot(p2.x-p1.x, p2.y-p1.y)当前距离 / 上一次距离累乘旋转角度两指连线方向 αMath.atan2(p2.y-p1.y, p2.x-p1.x)当前角度 - 上一次角度累加平移量两指中点 M((p1.xp2.x)/2, (p1.yp2.y)/2)当前中点 - 上一次中点累加关键点在于“增量”而不是“绝对值”。因为手指在屏幕上每动一毫米这三个量都同时发生了变化我们只关心这一帧相对上一帧的变化量然后叠进总状态里。这样就不会出现反复计算差值导致抖动的问题。实现时要注意每次 move 的最后一定要把当前的距离、角度和中点存下来作为下一帧的“上一次”基准。2.3 transform 的拼接顺序决定旋转后拖拽方向会不会乱第三块基础是 CSS transform 字符串的编码顺序。我通常用transform: translate(tx, ty) rotate(deg) scale(s); transform-origin: center;这里顺序是刻意排过的。CSS transform 的写法是从左到右作用到元素坐标系上的translate 放在最左边意思是先把图片挪到屏幕位置上然后再围绕图片中心做旋转和缩放。这样一来translate 的 tx、ty 始终是屏幕坐标系的位移旋转和缩放不会反向修改位移的方向图片旋转 180 度后手指往右拖图片依然往右走。如果顺序反过来变成rotate(deg) translate(tx, ty)意思就是先旋转坐标系再做位移图片转 90 度之后拖拽方向就会完全错乱这也是很多“旋转后拖拽翻车”案例的根源。另外transform-origin 固定为 center可以让旋转和缩放始终围绕图片中心进行数学简单也符合大多数图片查看器的直觉。至于“双指捏合时希望围绕手指中心缩放”的进阶需求我用 DOMMatrix 解决放在最后一章展开。单指旋转在这个方案里没有刻意支持因为单指旋转需要额外的旋转手柄和双指旋转的交互语义完全不同不能混用。3. 核心实现PointerEvent 追踪多指一次 move 更新全部状态3.1 维护指针表用 pointerId 区分每根手指先来实现事件骨架。这里用 PointerEvent 的 setPointerCapture把每个触点的后续事件都绑定到图片元素上避免手指滑出元素边界时事件丢失。class PinchZoom { constructor(el) { this.el el; this.pointers new Map(); // pointerId - {x, y} this.lastPinch null; // 上一帧双指的 {midx, midy, dist, angleDeg} this.state { x: 0, // 水平位移单位 px y: 0, // 垂直位移单位 px scale: 1, // 缩放倍数 rotate: 0, // 旋转角度单位 deg }; this.el.addEventListener(pointerdown, this.onPointerDown); this.el.addEventListener(pointermove, this.onPointerMove); this.el.addEventListener(pointerup, this.onPointerUp); this.el.addEventListener(pointercancel, this.onPointerUp); } onPointerDown (e) { // 捕获后续指针事件保证手指移出元素也不会断掉手势 this.el.setPointerCapture(e.pointerId); this.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); if (this.pointers.size 1) { // 第一个按下时记录单指拖拽的基准点 this.startPoint { x: e.clientX, y: e.clientY }; this.startOffset { x: this.state.x, y: this.state.y }; } else { // 第二根手指落下后双指几何由下一帧 move 来初始化 this.lastPinch null; } }; onPointerUp (e) { this.pointers.delete(e.pointerId); this.lastPinch null; // 双指变单指时重新计算一次基准避免图片跳变 if (this.pointers.size 1) { const remain [...this.pointers.values()][0]; this.startPoint { x: remain.x, y: remain.y }; this.startOffset { x: this.state.x, y: this.state.y }; } }; }onPointerDown 里有一个容易被忽略的细节setPointerCapture 必须在指针按下后立刻调用否则后面的 pointermove 只在手指没有移出元素边界时才有。尤其双指操作时手指经常超出图片边缘没有捕获的话手指一离开区域事件就停止送达手势会瞬间断掉。startPoint 和 startOffset 是单指拖拽的基准。state.x、state.y 是图片当前的绝对位移值单指按下时记录一次基准move 事件里就通过“基准点坐标差加上基准位移”得到当前目标位置而不是每次 framemove 都做累加。这样做的原因是避免小数累积误差也让双指变单指时能无缝切换。3.2 move 事件里同时结算位移、旋转、缩放接下来是核心中的核心onPointerMove 的实现。这里一次 move 事件会同时更新三个维度真正做到“同时拖拽、旋转、放大缩小”。onPointerMove (e) { if (!this.pointers.has(e.pointerId)) return; // 更新当前指针位置 this.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); const pts [...this.pointers.values()]; if (pts.length 1) { // 单指只更新拖拽 const p pts[0]; this.state.x this.startOffset.x (p.x - this.startPoint.x); this.state.y this.startOffset.y (p.y - this.startPoint.y); } else if (pts.length 2) { const [p1, p2] pts; const midx (p1.x p2.x) / 2; const midy (p1.y p2.y) / 2; const dist Math.hypot(p1.x - p2.x, p1.y - p2.y); const angleDeg Math.atan2(p2.y - p1.y, p2.x - p1.x) * 180 / Math.PI; if (!this.lastPinch) { // 第一帧只做初始化不更新状态 this.lastPinch { midx, midy, dist, angleDeg }; return; } // 1. 缩放当前距离 / 上一帧距离累乘 const scaleDelta dist / this.lastPinch.dist; this.state.scale Math.min(5, Math.max(0.2, this.state.scale * scaleDelta)); // 2. 旋转当前角度 - 上一帧角度累加 let rotateDelta angleDeg - this.lastPinch.angleDeg; rotateDelta ((rotateDelta 180) % 360 360) % 360 - 180; this.state.rotate rotateDelta; // 3. 平移双指中点的位移累加 this.state.x midx - this.lastPinch.midx; this.state.y midy - this.lastPinch.midy; this.lastPinch { midx, midy, dist, angleDeg }; } this.applyTransform(); }; applyTransform() { const { x, y, rotate, scale } this.state; this.el.style.transform translate(${x}px, ${y}px) rotate(${rotate}deg) scale(${scale}); }这段代码有几个地方值得单独说明。缩放时用的是“累乘”因为缩放本质是乘法关系。假设上一帧两指距离是 100px这一帧是 110px那这一帧缩放增量为 1.1图片整体放大 1.1 倍。而旋转用的是“累加”角度本质是加法关系上一帧两指连线角度是 30 度这一帧是 40 度那就多转 10 度。把这两个语义理清后面就不会在状态更新时搞错操作符。缩放上限和下限我取的是Math.min(5, Math.max(0.2, ...))。这两个值需要根据业务调整预览商品图时 0.5 倍到 4 倍可能就够白板画布通常要允许更大幅度。clamp 放在总 scale 的乘算之后但要注意如果你 clamp 后的值被截断画面可能会在到达临界值后继续平移这符合常见交互习惯。旋转角度用((rotateDelta 180) % 360 360) % 360 - 180做了一个归一化把角度差限制到 [-180, 180) 区间。这一步至关重要假设上一帧手指连线角度是 179 度这一帧变成了 -179 度直接相减会得到 -358 度图片会突然逆时针转一整圈。归一化之后这个差值是 2 度图片只转了 2 度这才是用户真实手势的意思。平移量用的是双指中点的位移增量。这里没有做任何坐标转换直接累加到 state.x / state.y因为 transform 的 translate 是屏幕坐标系的而 clientX / clientY 正好也是屏幕视口坐标系天然对齐。applyTransform 里的字符串拼接看着简单但顺序一定不能动translate 在最左rotate 居中scale 在最右。前面第二章已经解释过原因这里再强调一遍这个顺序让 rotate 和 scale 围绕图片中心作用不影响 translate 的方向。4. 挂到真实页面样式前提、坐标换算与渲染合并4.1 三行样式前提少一行就翻车代码逻辑本身不依赖任何框架但如果不把浏览器的默认行为挡住这套手势会在真机上立刻翻车。我在实际项目中第一个版本就栽在这里在 Android 上逻辑完全正常放到 iPhone 上双指一捏整个页面跟着缩放图片手势全乱。解决方式是在图片元素或父容器上设置这些样式.zoomable { touch-action: none; user-select: none; -webkit-user-drag: none; -webkit-touch-callout: none; will-change: transform; transform-origin: center; }touch-action: none 是最关键的一条。它告诉浏览器这个元素上的触摸操作由 js 接管禁止默认的滚动、双指缩放和长按行为。这条样式在 PointerEvent 体系里是官方推荐的配合项如果在 pointermove 里调用 e.preventDefault() 去拦截在部分浏览器尤其 Safari里并不稳定正确做法就是靠 CSS 声明。user-select 和 -webkit-user-drag 分别禁用文本选择和图片原生拖拽否则长按图片会触发系统的图片拖拽手势会中断。-webkit-touch-callout是 iOS 上禁用长按弹出菜单和保存图片的专属属性安卓端没有对应的标准属性需要再配合 pointerdown 里的某些处理比如短时间内禁止长按菜单但在绝大多数 WebView 里 touch-action: none 已经能挡住。有了这三行样式整套手势才算真正拿到事件的完全控制权。建议在开发时先用浏览器 DevTools 的设备模拟器测试打开触摸模拟能及时暴露 PC 端鼠标测试发现不了的默认行为冲突。4.2 事件坐标归一化别让图片位置受页面滚动影响有人会遇到这样的问题页面高度超过一屏上下滚动后图片手势的起始位置明显偏了。原因是事件对象里 layerX / offsetX 在部分浏览器对 PointerEvent 的兼容性不一致而 clientX / clientY 始终是视口坐标一旦页面滚动图片元素在视口内的位置发生变化直接拿 client 坐标去和 transform 的位移做运算就会产生偏移。常见的做法是把事件坐标先换算成元素容器坐标系再做手势计算。我的做法是在初始化时拿到元素相对于视口的偏移然后在每次 pointermove 里减去这个偏移量const rect this.el.parentElement.getBoundingClientRect(); onPointerDown (e) { this.offsetX rect.left; this.offsetY rect.top; const x e.clientX - this.offsetX; const y e.clientY - this.offsetY; // 后续所有计算都用 x / y而不是 clientX / clientY };这样换算之后状态里的 x、y 实际上就是图片在容器内的坐标不受页面滚动影响。这里要注意不要在每次 move 里重新调用 getBoundingClientRect因为滚动会引起布局变化但 move 事件里做 layout 查询会强制触发重排拉低触摸帧率。正确的做法是在 touchstart / pointerdown 时记录一次如果页面真的会在手势过程中被滚动通常这不是图片交互场景该发生的事。4.3 用 requestAnimationFrame 合并别让 transform 每帧写太多次触摸设备的 move 事件触发频率可以到每秒 100 次以上而屏幕刷新率通常是 60Hz。如果每一次 move 事件都直接写 style.transform那么同一帧里可能被写入 2 到 3 次样式浏览器就要反复做样式计算和合成手指一快就明显掉帧。我一般会做一个 rAF 合并把最新的 state 缓存下来等浏览器下一帧再统一写入 transformscheduleTransform() { if (this.rafId) return; this.rafId requestAnimationFrame(() { this.rafId 0; const { x, y, rotate, scale } this.state; this.el.style.transform translate(${x}px, ${y}px) rotate(${rotate}deg) scale(${scale}); }); }把原来 applyTransform 里的字符串拼接挪进 rAF 回调move 事件里只更新 state 和调用 scheduleTransform。这个优化场景下效果非常明显即使 move 事件一小时触发几百次真正写样式的地方也只有 60 帧既省了 CPU 也没让交互变迟钝。还有一个落地细节值得顺手做掉双击重置。用户把图片拖到角落放大到 5 倍后经常想快速恢复初始状态。监听 dblclick 事件把 state 重置为初始值然后通过 CSS transition 让 transform 在 0.3 秒内平滑回位。注意重置时要取消 transition否则拖拽过程中也会带着过渡效果导致手指跟手延迟。5. 避坑笔记五个“完美”背后的翻车现场与应对5.1 iOS 上双指一碰页面先跟着缩放了现象在 iPhone Safari 里双指捏合图片时页面整体缩放图片手势完全无法操作Android Chrome 却正常。原因iOS Safari 对 touch-action 的处理更敏感。只要页面视口配置了user-scalableno或 viewport 的 maximum-scale 被允许双指缩放就容易被浏览器抢走。很多站点只在 meta viewport 里写了 initial-scale没禁掉最大缩放。解决meta nameviewport contentwidthdevice-width, initial-scale1, maximum-scale1, user-scalableno同时必须在目标元素上确保touch-action: none生效。注意 user-scalableno 在部分 iOS 版本上对 Safari 主浏览器已经失效iOS 10 后默认允许缩放真正可靠的手段还是靠 touch-action 这条样式。如果这两层都做了还是不行检查是不是有透明遮罩层在图片上层抢占了事件。5.2 双指旋转接近 180 度时图片突然猛转一圈现象双指缓慢旋转图片转到 180 度附近时图片突然反向快速转了大半圈像抽搐一样。原因angleDeg 用Math.atan2计算结果范围只有 [-180, 180)。当上一帧是 179这一帧变成 -179 时直接相减得到 -358程序认为你要逆时针转 358 度于是图片猛甩一圈。解决对角度差做归一化把它夹到 [-180, 180) 区间。代码就是第 3 章里那段rotateDelta ((rotateDelta 180) % 360 360) % 360 - 180。这条属于那种“不遇到永远想不到”的坑建议直接写成一个工具函数function normalizeAngleDelta(delta) { return ((delta 180) % 360 360) % 360 - 180; }5.3 双指捏合后抬起一根手指图片瞬间“蹦”一下现象双指缩放中突然抬起一根手指图片的位置会跳变一个明显距离好像被弹了一下。原因这是单指拖拽基准 startPoint 没重新归属导致的。双指抬起一根后剩下那根手指的坐标和双指中点本来就有差距而单指拖拽逻辑里 state.x 是“当前手指位置减去 startPoint 再加上 startOffset”。如果 startPoint 还是最早那根手指按下时的位置差值当然不对。解决在 pointerup 删除触点后如果剩下一个触点立刻把 startPoint 重置为当前剩余触点的坐标startOffset 重置为当前 state 的值。第 3 章 onPointerUp 的代码里已经带了这段逻辑。要验证这个 bug 是否修干净可以连续做几十次“两指缩放再抬起一指拖拽”的动作观察每次切换瞬间图片是否跟手。5.4 图片旋转 45 度后边界限制完全失控现象没做边界限制时图片可以被随意拖飞出屏幕。加了 clamp 限制之后旋转 90 度后限制边界明显不对图片卡在奇怪的位置。原因边界限制的基础是“未旋转时图片中心可以移动的区域”。一旦图片旋转了图片的投影包围盒变成了旋转矩形的外接四边形宽高不再是原始宽高乘以 scale边界就完全变了。直接用 width * scale 算 maxOffset在旋转 30 度以上时就会出问题。解决先不追求通用情况。我的常用策略是把旋转角度先限制到 0、90、180、270 四个方向边界就可以简单计算如果必须支持任意旋转角需要把图片四个角点用当前 transform 矩阵投影到容器坐标再取外接矩形做 clamp。实现量不小建议评估业务里是否真的需要。如果只是图片预览四方向对齐已经能满足大多数场景。想做得更“完美”DOMMatrix 的方式在下一章会给出方向。5.5 图片长按弹出系统保存菜单现象在手机上长按图片抖了一下之后弹出系统菜单手势操作被强制中断。原因浏览器对 img 元素有默认长按行为会触发图片拖拽或上下文菜单。这个问题在 iOS 上表现为图片变成半透明可以被拖走在 Android 上表现为弹出菜单。用户本意是想按住图片做双指缩放结果系统先动手了。解决在图片元素上同时设置-webkit-user-drag: none; -webkit-touch-callout: none; user-select: none; pointer-events: auto;如果还是不生效可以把交互目标从 img 换成包裹它的 div给 div 设置背景图的方式来显示图片。div 没有图片的原生长按行为问题从根源上消失。这个方案在 uni-app 和 WebView 里我都用过属于最稳妥的后手。6. 进阶用 DOMMatrix 统一管理变换校准累计误差前面这套 state 方案x、y、scale、rotate 四个变量简单直观但它有一个边界transform-origin 只能固定在图片中心。真正接近原生地图缩放的体验需要让缩放围绕双指中点的屏幕位置展开。要实现这个效果就要把“围绕中心缩放”换成“围绕任意点变换”此时手写坐标会变得很绕我通常会用 DOMMatrix 来接管。DOMMatrix 是浏览器内置的 2D/3D 变换矩阵对象可以通过 translate、rotate、scale 方法链式构造矩阵最后 toString 直接得到 CSS 用的 matrix()。关键区别在于CSS transform 字符串的顺序是隐性约束矩阵乘法顺序更明确而且可以中途用 DOMPoint 去变换图片四个角的坐标做旋转后的边界计算。下面是一段缩放围绕双指中点的代码思路let m new DOMMatrix(); // 以双指中点为锚点做缩放先平移到锚点缩放再平移回来 m m.translate(midx, midy) .scale(scaleDelta) .translate(-midx, -midy); // 再叠加上一帧的位移与旋转 m m .translate(deltaX, deltaY) .rotate(rotateDelta);视觉中心的问题是这样解决的传统 transform-origin 固定在元素中心时双指在图片左侧捏合图片会中心放大而不是手指位置放大。用 DOMMatrix 把缩放锚点换成双指中点用户体验会立刻逼近原生应用。代价是你不再拥有干净的 x、y、scale、rotate 四个状态值矩阵的 6 个参数CSS matrix(a,b,c,d,e,f)成了新的状态调试门槛会高一些。我平时会顺便做一个验证习惯用一个双层状态方案DOM 用 state 方案同时在内存里维护一个 DOMMatrix每次手势操作完成后把两份状态各自算出四个角点的坐标做对比误差超过 1e-6 就停下来检查。这个习惯帮我抓到过一次精度问题rotate 累加到几万度后Math.sin / Math.cos 的取值已经不稳定CSS rotate 本身不在乎角度大小但如果是自己维护矩阵就应该定期把角度取模到 360 度。如果最初就接受了这套逻辑后面就不会出现“转了几十圈后图片慢慢飘走”的玄学现象。到这里整个方案已经足够支撑一个能交付的图片手势组件。先跑通第 3 章的最小状态机再逐步加入面板限制和 DOMMatrix 锚点你会对浏览器的手势事件体系有完整的掌控感。我自己的经验是手势交互永远要在真机上测PC 模拟器只能验证逻辑希望这篇笔记里踩过的坑能帮你少走一程。希望帮到你。本文还有配套的精品资源点击获取
返回列表