前端知库

移动端手势交互怎样避免和页面滚动冲突?

用拖动卡片说明 Pointer Events、pointer capture 和 touch-action 的关系,处理取消、丢失指针、多点触控和键盘/无障碍降级。

参考答案

简答

  • 移动端手势要和浏览器的滚动、缩放、点击判断共存。
  • 无条件阻止默认行为会让页面无法滚动,完全不管又会被滚动打断。
  • Pointer Events 统一了输入模型,但不是手势识别器。
  • 方向、阈值、速度和取消规则要自己定义,清理时移除监听并释放捕获。

移动端的拖动、滑动和缩放不是“监听 touchmove 然后改 left”这么简单。浏览器同时要判断用户是在滚动页面、缩放、点击链接还是操作组件;如果组件无条件阻止默认行为,页面可能无法滚动,如果完全不管默认行为,拖动又会被浏览器滚动打断。

Pointer Events 统一了输入模型

Pointer Events 可以用同一套事件处理鼠标、触摸和触控笔,并通过 pointerId 区分同时存在的指针。它不是自动的手势识别器,开发者仍需定义拖动方向、阈值、速度和取消规则。

JavaScript
function bindHorizontalDrag(element, onMove) {
  let activeId = null;
  let originX = 0;
  let lastX = 0;

  function finish(event) {
    if (event.pointerId !== activeId) return;
    activeId = null;
    element.releasePointerCapture?.(event.pointerId);
  }

  function down(event) {
    if (activeId !== null || event.button !== 0) return;
    activeId = event.pointerId;
    originX = lastX = event.clientX;
    element.setPointerCapture?.(event.pointerId);
  }

  function move(event) {
    if (event.pointerId !== activeId) return;
    const delta = event.clientX - originX;
    lastX = event.clientX;
    onMove({ delta, position: lastX, event });
  }

  function lost(event) {
    if (event.pointerId === activeId) activeId = null;
  }

  const controller = new AbortController();
  const { signal } = controller;
  element.addEventListener('pointerdown', down, { signal });
  element.addEventListener('pointermove', move, { signal });
  element.addEventListener('pointerup', finish, { signal });
  element.addEventListener('pointercancel', finish, { signal });
  element.addEventListener('lostpointercapture', lost, { signal });

  // 清理时同时移除监听并释放仍在进行的捕获
  return () => {
    if (activeId !== null && element.hasPointerCapture?.(activeId)) {
      element.releasePointerCapture(activeId);
    }
    activeId = null;
    controller.abort();
  };
}

setPointerCapture 让元素在指针离开自身边界后仍能收到该指针的后续事件,适合拖动;它不保证设备断电、浏览器切后台或系统手势时一定收到 pointerup,所以要处理 pointercancel 和 lostpointercapture。清理时还要停止惯性动画、计时器和未完成的业务操作。

touch-action 先表达允许浏览器做什么

CSS
.horizontal-slider {
  touch-action: pan-y;
  user-select: none;
}

这个声明表示组件处理水平方向手势时,允许浏览器保留垂直滚动。它比在每个 touchmove 中无条件调用 preventDefault() 更早、更清楚地表达交互边界。

touch-action: none 会禁用该区域的浏览器平移和缩放能力,只有确实需要完全自定义手势时才使用。禁用默认行为后,应用要自己提供滚动、缩放、取消和无障碍替代路径,不能只追求“拖起来了”。

手势判定应保留一个启动阈值:用户轻触按钮时不要立刻把它当成拖动;超过阈值后再捕获指针、更新位置,并决定是否取消点击。不同平台的点击延迟、滚动锁定和系统手势还需真实设备验证。

多点触控需要按 pointerId 管理

单指拖动和双指缩放不能共用一个全局 isDragging。应为每个 pointerId 保存位置,并在第二个指针出现时切换到明确的多点状态;不要把两个手指的事件简单相加。

计算缩放时要考虑视口坐标、CSS transform 和设备像素比。Canvas、地图和图片编辑器还要保持内容坐标与屏幕坐标的变换一致,避免触摸位置和绘制位置错位。手势结束或取消时,所有 pointer 状态都要清理,不能等待一个永远不会到达的 pointerup。

事件处理性能不能靠阻止默认行为解决

pointermove 可能高频触发,处理器应合并更新,在 requestAnimationFrame 中写入视觉状态,避免每个事件都读取布局再修改样式。不要在移动事件里执行同步布局、复杂 React/Vue 状态树更新或大规模 DOM 查询。

如果只需要绘制轨迹,可以批量保存坐标并在帧中消费;如果需要高频原始输入,可评估 pointerrawupdate,但兼容性、数据量和功耗需要单独验证。passive 监听器不能调用 preventDefault,而 touch-action 往往是声明滚动行为的首选。

手势必须有非手势入口

滑动删除应提供按钮或菜单操作,拖拽排序应提供上移/下移、输入框或明确的键盘命令;缩放和旋转控件应能通过键盘或表单调整。只依赖触摸轨迹会排除键盘用户、读屏用户、运动障碍用户和无法完成精细手势的设备。

焦点、点击目标和取消操作要明确。用户开始拖动后按 Escape、移出应用或系统中断时,组件应回到可解释状态;不要因为一次未完成手势让按钮永久锁定或页面无法滚动。

测试应覆盖鼠标、单指、双指、触控笔、快速移动、滚动冲突、pointercancel、设备旋转、页面切后台、键盘替代操作和真实低端设备。手势代码通过桌面模拟器,只能证明部分事件逻辑,不能证明浏览器滚动、系统手势和触摸延迟已经正确。