简介:基于 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 上。
把这三个量做成一张对照表,方便直接对照代码:
| 计算目标 | 手势输入 | 公式 | 更新方式 |
|---|---|---|---|
| 缩放倍数 | 两指距离 d | Math.hypot(p2.x-p1.x, p2.y-p1.y) | 当前距离 / 上一次距离,累乘 |
| 旋转角度 | 两指连线方向 α | Math.atan2(p2.y-p1.y, p2.x-p1.x) | 当前角度 - 上一次角度,累加 |
| 平移量 | 两指中点 M | ((p1.x+p2.x)/2, (p1.y+p2.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 缓存下来,等浏览器下一帧再统一写入 transform:
scheduleTransform() { 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-scalable=no或 viewport 的 maximum-scale 被允许,双指缩放就容易被浏览器抢走。很多站点只在 meta viewport 里写了 initial-scale,没禁掉最大缩放。
解决:
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">同时必须在目标元素上确保touch-action: none生效。注意 user-scalable=no 在部分 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 模拟器只能验证逻辑,希望这篇笔记里踩过的坑能帮你少走一程。希望帮到你。
本文还有配套的精品资源,点击获取