CSS Transform中Rotate属性的实战应用与常见误区解析
又踩坑了,移动端旋转手势搞崩了整个页面
上周在做一个移动端的图片预览组件,用户可以双指缩放、单指拖动,当然也得支持旋转。本来以为用 transform: rotate() 搞定就完事了,结果一上真机,问题一个接一个:旋转后拖动位置错乱、缩放中心点偏移、甚至整个页面都卡死不动。折腾了整整一天,差点把键盘砸了。
一开始我天真地以为加个 rotate 就行
最开始的思路特别简单:监听 touch 事件,计算两指之间的角度变化,然后直接给元素加一个 transform: rotate(${angle}deg)。代码写得飞快:
let startAngle = 0;
let currentAngle = 0;
element.addEventListener('touchstart', (e) => {
if (e.touches.length === 2) {
startAngle = getAngle(e.touches[0], e.touches[1]);
}
});
element.addEventListener('touchmove', (e) => {
if (e.touches.length === 2) {
const nowAngle = getAngle(e.touches[0], e.touches[1]);
currentAngle += nowAngle - startAngle;
startAngle = nowAngle;
element.style.transform = rotate(${currentAngle}deg);
}
});
function getAngle(p1, p2) {
return Math.atan2(p2.clientY - p1.clientY, p2.clientX - p1.clientX) * 180 / Math.PI;
}
本地 Chrome 模拟器跑得挺顺,但一上 iPhone,问题立马来了:手指稍微一动,图片就疯狂旋转,而且方向完全不对。更诡异的是,旋转之后再拖动,图片直接飞到屏幕外去了——根本找不到在哪。
排查过程:原来 transform 是叠加的,不是覆盖的
我一开始以为是角度计算错了,反复检查 getAngle 函数,还换了好几种三角函数实现。后来用 console.log 打印每一步的 angle,发现数值其实是对的。那问题出在哪?
突然意识到:我每次只设置了 rotate,但之前的 translate(拖动位移)和 scale(缩放)全被清掉了!因为 element.style.transform = 'rotate(...)' 是直接覆盖整个 transform 属性,而不是追加。
这就解释了为什么拖动失效——旋转之后,之前记录的 translateX/Y 全丢了,下次拖动时从 (0,0) 开始算,自然就“飞”了。
这里我踩了个大坑:**transform 的各个函数必须写在一起,不能分开赋值**。你不能今天 set rotate,明天 set translate,后天 set scale,那样会互相覆盖。
解决方案:把所有 transform 状态集中管理
想通之后,我改了策略:不再直接操作 style.transform,而是维护一个状态对象,包含 scale、rotate、translateX、translateY 四个属性。每次 touchmove 变化时,更新对应的状态,然后统一拼成一个 transform 字符串。
核心代码长这样:
const state = {
scale: 1,
rotate: 0,
translateX: 0,
translateY: 0
};
function updateTransform() {
element.style.transform =
translate(${state.translateX}px, ${state.translateY}px)
rotate(${state.rotate}deg)
scale(${state.scale})
;
}
注意顺序!transform 的顺序会影响最终效果。我试过把 rotate 放在 translate 前面,结果旋转时图片会绕着原点转,而不是绕着当前中心点转。正确的顺序应该是:先 translate(定位到当前位置),再 rotate(绕当前中心旋转),最后 scale(以旋转后的中心缩放)。
不过这里有个细节:默认的 transform-origin 是 center,所以 rotate 和 scale 都是以元素中心为基准。但如果你在旋转后拖动,其实整个坐标系已经变了,这时候再计算 drag 的位移,得考虑当前的旋转角度。
拖动时的位置校正:别忘了旋转对坐标的影响
这才是最头疼的部分。假设图片已经旋转了 30 度,用户单指拖动。这时候,手指在屏幕上的移动方向(比如向右)并不等于图片应该移动的方向(因为图片歪了)。如果不做校正,用户会觉得“拖不动”或者“方向反了”。
解决办法是:在计算 drag 位移时,把当前的旋转角度考虑进去,做一次坐标变换。具体来说,就是把屏幕坐标系下的 delta 转换到图片当前的局部坐标系下。
// 假设 deltaX, deltaY 是屏幕上的位移
const rad = state.rotate * Math.PI / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
// 逆时针旋转 -rad(即顺时针旋转 rad)来抵消当前旋转的影响
const localDeltaX = deltaX * cos + deltaY * sin;
const localDeltaY = -deltaX * sin + deltaY * cos;
state.translateX += localDeltaX;
state.translateY += localDeltaY;
updateTransform();
这段数学公式我查了好久,亲测有效。原理是:当前图片坐标系相对于屏幕坐标系旋转了 state.rotate 度,所以要把屏幕上的位移向量反向旋转同样的角度,才能得到图片本地坐标系下的真实位移。
不过说实话,这个方案在旋转角度接近 90/180/270 度时偶尔会有轻微抖动,但不影响使用,我就没深究了——毕竟优先级不高,能用就行。
完整的核心逻辑(带注释版)
把上面的思路整合一下,一个基本可用的旋转+拖动+缩放 demo 就出来了。以下是精简后的核心代码:
let isPinching = false;
let startVector = null;
let startDistance = 0;
let startAngle = 0;
let lastTouchCenter = null;
const state = {
scale: 1,
rotate: 0,
translateX: 0,
translateY: 0
};
function getDistance(p1, p2) {
return Math.sqrt(Math.pow(p2.clientX - p1.clientX, 2) + Math.pow(p2.clientY - p1.clientY, 2));
}
function getCenter(p1, p2) {
return {
x: (p1.clientX + p2.clientX) / 2,
y: (p1.clientY + p2.clientY) / 2
};
}
function getAngle(p1, p2) {
return Math.atan2(p2.clientY - p1.clientY, p2.clientX - p1.clientX) * 180 / Math.PI;
}
function updateTransform() {
element.style.transform =
translate(${state.translateX}px, ${state.translateY}px)
rotate(${state.rotate}deg)
scale(${state.scale})
;
}
element.addEventListener('touchstart', (e) => {
if (e.touches.length === 1) {
// 单指拖动准备
lastTouchCenter = { x: e.touches[0].clientX, y: e.touches[0].clientY };
} else if (e.touches.length === 2) {
// 双指操作
isPinching = true;
startDistance = getDistance(e.touches[0], e.touches[1]);
startAngle = getAngle(e.touches[0], e.touches[1]);
startVector = { x: e.touches[0].clientX, y: e.touches[0].clientY };
lastTouchCenter = getCenter(e.touches[0], e.touches[1]);
}
});
element.addEventListener('touchmove', (e) => {
e.preventDefault(); // 阻止默认滚动
if (e.touches.length === 1 && !isPinching) {
// 单指拖动
const deltaX = e.touches[0].clientX - lastTouchCenter.x;
const deltaY = e.touches[0].clientY - lastTouchCenter.y;
const rad = state.rotate * Math.PI / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
state.translateX += deltaX * cos + deltaY * sin;
state.translateY += -deltaX * sin + deltaY * cos;
lastTouchCenter = { x: e.touches[0].clientX, y: e.touches[0].clientY };
updateTransform();
} else if (e.touches.length === 2) {
// 双指缩放 + 旋转
const currentDistance = getDistance(e.touches[0], e.touches[1]);
const currentAngle = getAngle(e.touches[0], e.touches[1]);
const currentCenter = getCenter(e.touches[0], e.touches[1]);
// 缩放
state.scale *= currentDistance / startDistance;
// 旋转
state.rotate += currentAngle - startAngle;
// 更新起始状态
startDistance = currentDistance;
startAngle = currentAngle;
lastTouchCenter = currentCenter;
updateTransform();
}
});
element.addEventListener('touchend', () => {
isPinching = false;
});
这段代码在 iOS 和 Android 主流机型上都跑通了。虽然还有些边界情况没处理(比如快速切换单双指、三指操作等),但核心功能稳了。
一点小提醒
- 一定要加
e.preventDefault(),否则 touchmove 会被浏览器默认行为(比如页面滚动)打断。 - transform 顺序别乱调,我试过把 scale 放最前,结果缩放中心点乱飘。
- 如果图片本身有宽高限制,记得设置
transform-origin: center,避免旋转时偏移。
结尾碎碎念
以上是我踩坑后的总结,虽然代码看起来有点啰嗦,但胜在稳定。其实网上也有用矩阵(matrix)直接操作的方案,更底层但也更难调试,我这种糙快猛的开发者还是喜欢分状态管理。如果你有更好的方案,比如用 PointerEvent 或者更优雅的坐标变换,欢迎评论区交流!
这个技巧的拓展用法还有很多,比如结合 CSS transition 做惯性回弹,或者限制旋转角度范围。后续有空再写一篇吧,现在先去修另一个 bug 了……

暂无评论