移动端手势交互怎样避免和页面滚动冲突?
用拖动卡片说明 Pointer Events、pointer capture 和 touch-action 的关系,处理取消、丢失指针、多点触控和键盘/无障碍降级。
简答
- 移动端手势要和浏览器的滚动、缩放、点击判断共存。
- 无条件阻止默认行为会让页面无法滚动,完全不管又会被滚动打断。
- Pointer Events 统一了输入模型,但不是手势识别器。
- 方向、阈值、速度和取消规则要自己定义,清理时移除监听并释放捕获。
移动端的拖动、滑动和缩放不是“监听 touchmove 然后改 left”这么简单。浏览器同时要判断用户是在滚动页面、缩放、点击链接还是操作组件;如果组件无条件阻止默认行为,页面可能无法滚动,如果完全不管默认行为,拖动又会被浏览器滚动打断。
Pointer Events 统一了输入模型
Pointer Events 可以用同一套事件处理鼠标、触摸和触控笔,并通过 pointerId 区分同时存在的指针。它不是自动的手势识别器,开发者仍需定义拖动方向、阈值、速度和取消规则。
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 先表达允许浏览器做什么
.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、设备旋转、页面切后台、键盘替代操作和真实低端设备。手势代码通过桌面模拟器,只能证明部分事件逻辑,不能证明浏览器滚动、系统手势和触摸延迟已经正确。