多点触控技术实现原理与前端开发实战经验分享

胜楠 Dev 交互 阅读 527
赞 26 收藏
二维码
手机扫码查看
反馈

又踩坑了,多点触控手势识别乱成一锅粥

上周在搞一个画板功能,用户要在手机上用两个手指缩放、旋转画布。听起来挺简单的对吧?结果 touchstart、touchmove、touchend 一套下来,手指一多就各种鬼畜:缩放方向反了、旋转角度跳变、甚至单指拖动也会触发双指逻辑。折腾了整整两天,头发都快薅秃了。

多点触控技术实现原理与前端开发实战经验分享

一开始以为是数学问题,其实是事件处理顺序搞错了

最开始我天真地以为只要拿到两个 touch 点的坐标,算个距离变化就是缩放比例,算个向量夹角就是旋转角度。代码写得还挺“优雅”:

let startDistance = 0;
let startAngle = 0;

function handleTouchStart(e) {
  if (e.touches.length === 2) {
    const [t1, t2] = e.touches;
    startDistance = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
    startAngle = Math.atan2(t2.clientY - t1.clientY, t2.clientX - t1.clientX);
  }
}

function handleTouchMove(e) {
  if (e.touches.length === 2) {
    const [t1, t2] = e.touches;
    const currentDistance = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
    const currentAngle = Math.atan2(t2.clientY - t1.clientY, t2.clientX - t1.clientX);
    
    const scale = currentDistance / startDistance;
    const rotate = (currentAngle - startAngle) * 180 / Math.PI;
    // 应用 transform...
  }
}

结果一上手就翻车:手指稍微抖一下,或者第三个手指不小心碰到屏幕(比如握持姿势),e.touches.length 就变成 3 了,整个逻辑直接崩掉。更恶心的是,iOS 上有时候 touchmove 事件里 touches 数量会突然从 2 跳到 1 再跳回 2,导致计算出来的 scale 和 rotate 像抽风一样。

关键教训:别只看 touches.length,要看 identifier!

后来翻 MDN 文档才发现,每个 touch 对象有个 identifier 属性,从 touchstart 开始一直到 touchend 都不会变。也就是说,真正靠谱的做法不是数手指数量,而是记录我们关心的那两个手指的 identifier,后续只认这两个 ID。

这里我踩了个大坑:一开始我还以为 identifier 是递增的整数,结果发现不同浏览器行为不一致,有的从 0 开始,有的随机数。所以绝对不能假设 identifier 的值,只能存储并匹配。

核心代码就这几行

重构后的思路很清晰:

  • touchstart 时,如果检测到两个 touch,就把它们的 identifier 存起来
  • 后续 touchmove 只处理包含这两个 identifier 的事件
  • 任意一个手指离开(touchendtouchcancel),就重置状态

完整实现如下(已亲测在 iOS Safari 和 Android Chrome 正常工作):

let trackedTouches = null; // 存储两个手指的 identifier
let initialTransform = { scale: 1, rotate: 0, dx: 0, dy: 0 };

function getTouchByIdentifier(touches, id) {
  for (let i = 0; i < touches.length; i++) {
    if (touches[i].identifier === id) return touches[i];
  }
  return null;
}

function calculateGesture(touches) {
  if (!trackedTouches) return null;
  
  const t1 = getTouchByIdentifier(touches, trackedTouches.id1);
  const t2 = getTouchByIdentifier(touches, trackedTouches.id2);
  
  if (!t1 || !t2) return null; // 其中一个手指已经离开了
  
  const x1 = t1.clientX, y1 = t1.clientY;
  const x2 = t2.clientX, y2 = t2.clientY;
  
  const distance = Math.hypot(x2 - x1, y2 - y1);
  const angle = Math.atan2(y2 - y1, x2 - x1);
  
  return { distance, angle };
}

function handleTouchStart(e) {
  if (e.touches.length === 2 && !trackedTouches) {
    // 开始追踪这两个手指
    trackedTouches = {
      id1: e.touches[0].identifier,
      id2: e.touches[1].identifier
    };
    
    const gesture = calculateGesture(e.touches);
    if (gesture) {
      initialTransform.scale = 1;
      initialTransform.distance = gesture.distance;
      initialTransform.angle = gesture.angle;
    }
    e.preventDefault(); // 阻止默认缩放
  }
}

function handleTouchMove(e) {
  if (!trackedTouches) return;
  
  const gesture = calculateGesture(e.touches);
  if (!gesture) return;
  
  const scale = gesture.distance / initialTransform.distance;
  const rotate = (gesture.angle - initialTransform.angle) * 180 / Math.PI;
  
  // 这里应用你的 transform 逻辑
  applyTransform({ scale, rotate });
  
  e.preventDefault(); // 必须阻止默认行为,否则页面会滚动或缩放
}

function handleTouchEnd(e) {
  // 检查是否是我们追踪的手指离开了
  if (trackedTouches) {
    let shouldReset = false;
    for (let i = 0; i < e.changedTouches.length; i++) {
      const id = e.changedTouches[i].identifier;
      if (id === trackedTouches.id1 || id === trackedTouches.id2) {
        shouldReset = true;
        break;
      }
    }
    if (shouldReset) {
      trackedTouches = null;
    }
  }
}

// 绑定事件(记得用 passive: false,否则 preventDefault 无效)
element.addEventListener('touchstart', handleTouchStart, { passive: false });
element.addEventListener('touchmove', handleTouchMove, { passive: false });
element.addEventListener('touchend', handleTouchEnd, { passive: false });
element.addEventListener('touchcancel', () => { trackedTouches = null; }, { passive: false });

踩坑提醒:这三点一定注意

第一,passive: false 是必须的。现代浏览器为了滚动性能,默认把 touch 事件设为 passive,这时候调用 preventDefault() 会报错且无效。结果就是页面跟着手指滚动,你的手势根本没法用。这点在 Chrome DevTools 里会有警告,但很多人忽略。

第二,别忘了 touchcancel。iOS 在某些情况下(比如来电、系统弹窗)会触发 touchcancel 而不是 touchend,不处理的话状态就卡住了,下次手势直接失效。

第三,单指和多指要分开处理。上面代码只处理双指,如果你还需要单指拖动,得另外写一套逻辑,并且确保两者互不干扰。我的做法是:一旦进入双指模式,就禁止单指逻辑;双指结束才重新允许单指。

改完后还有一两个小问题,但无大碍

现在基本稳了,不过在低端 Android 机上偶尔会出现轻微的“跳帧”——就是旋转时角度突然跳一下。怀疑是 touchmove 触发频率不够高,或者系统做了 touch 合并。但用户反馈说不影响使用,就没深究了。毕竟不是做专业绘图软件,能用就行。

另外,如果要做三指、四指手势,这个模式也能扩展,就是代码会更啰嗦。不过现实中真用三指操作的场景太少了,我暂时没遇到。

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。比如有没有现成的库能处理得更优雅?或者对 touch 事件流有更深的理解?求分享!

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

暂无评论