CSS Transform中Rotate属性的实战应用与常见误区解析

开发者晨羲 移动 阅读 813
赞 9 收藏
二维码
手机扫码查看
反馈

又踩坑了,移动端旋转手势搞崩了整个页面

上周在做一个移动端的图片预览组件,用户可以双指缩放、单指拖动,当然也得支持旋转。本来以为用 transform: rotate() 搞定就完事了,结果一上真机,问题一个接一个:旋转后拖动位置错乱、缩放中心点偏移、甚至整个页面都卡死不动。折腾了整整一天,差点把键盘砸了。

CSS 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 了……

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论