拖拽惯性要解决的核心问题是:手指离开屏幕之后,modal 继续移动多远、以什么样的减速曲线停下来。微信小程序提供的触摸事件已经包含足够信息,只是不能在 touchend 里获取系统级惯性参数,需要自己根据 touchmove 记录的速度近似计算。下面先给出一个可运行的自定义 modal 拖拽基础结构,然后分别讨论速度采样、距离公式、边界回弹和性能优化。

一、速度采样:把 touchmove 转成可计算的末速度
触摸事件对象中,e.touches[0].clientY 表示当前触摸点的纵坐标,时间戳可以用 Date.now() 或 e.timeStamp。由于 touchmove 触发频率高,手指微小抖动会带来速度噪声,所以不能只取最后两个点计算瞬时速度,最好维护一个最近速度数组。取最近 5 次采样做平均,或者对速度做指数平滑,可以让最终传入惯性计算的末速度更稳定。
下面是 Page 中触摸事件的基础实现,主要完成三件事:记录起始位置、在每次移动时更新 modal 位移、把最近几帧的位移与时间差存入速度队列。
Page({
data: {
modalY: 0,
dragging: false
},
onTouchStart(e) {
const touch = e.touches[0];
this.dragState = {
startY: touch.clientY,
lastY: touch.clientY,
lastTime: Date.now(),
velocities: []
};
},
onTouchMove(e) {
const touch = e.touches[0];
const state = this.dragState;
if (!state) return;
const now = Date.now();
const dt = now - state.lastTime;
if (dt > 0) {
const dy = touch.clientY - state.lastY;
const velocity = dy / dt;
state.velocities.push(velocity);
if (state.velocities.length > 5) {
state.velocities.shift();
}
}
const moveY = touch.clientY - state.startY;
this.setData({
modalY: moveY
});
state.lastY = touch.clientY;
state.lastTime = now;
},
onTouchEnd() {
const state = this.dragState;
if (!state) return;
const velocity = this.getFinalVelocity(state.velocities);
const distance = this.calcInertiaDistance(velocity);
this.startInertia(distance);
this.dragState = null;
}
});
速度单位在这里是像素每毫秒,因为 clientY 是像素,时间差是毫秒。实际取 5 次速度队列是为了抹平最后几帧的抖动,如果希望更跟手,可以增加最近一帧的权重,例如使用 0.7 和 0.3 的指数平滑。得到末速度后,下一步就是把它转换成距离。
二、惯性距离计算与减速动画
如果把松手后的减速过程简化为匀减速运动,设末速度为 v0,摩擦减速度为 a,最终速度归零时满足 v0² = 2as,所以惯性距离 s = v0² / (2a)。这里 a 决定阻尼感,越小滑得越远,越大停得越快。以 0.002 px/ms² 为例,末速度 0.6 px/ms 对应的距离约为 90px,属于轻扫的常规范围。
计算时代码可以写成:
getFinalVelocity(velocities) {
if (!velocities || velocities.length === 0) return 0;
const recent = velocities.slice(-3);
const sum = recent.reduce((acc, v) => acc + v, 0);
return sum / recent.length;
},
calcInertiaDistance(velocity) {
const deceleration = 0.002;
const direction = velocity > 0 ? 1 : -1;
const distance = (velocity * velocity) / (2 * deceleration);
return distance * direction;
}
注意 velocity 带符号,平方后要再乘方向,否则向上拖动也会被算成正距离。计算得到惯性距离后,不能一次性把目标位置写进 modalY,否则会显得跳变,需要配合动画逐帧过渡。可以用 setInterval 以 16ms 左右的间隔更新位置,并套用 ease-out 三次缓动,让减速更自然。
startInertia(distance) {
const startY = this.data.modalY;
const targetY = this.clampTarget(startY + distance);
const change = targetY - startY;
const duration = Math.min(Math.abs(distance) / 0.4, 500);
const startTime = Date.now();
clearInterval(this.inertiaTimer);
this.inertiaTimer = setInterval(() => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
this.setData({
modalY: startY + change * eased
});
if (progress >= 1) {
clearInterval(this.inertiaTimer);
this.inertiaTimer = null;
}
}, 16);
}
上面把动画时长与惯性距离做了简单关联:距离越大,动画持续时间会略长,但最长限制在 500ms,避免长距离滑动拖沓。定时器在页面卸载时要注意清理,否则可能造成内存泄漏。基础库 2.9.0 以上还可以用 this.animate 直接驱动节点动画,性能更好,但需要提前给节点配置动画参数。
三、边界限制与越界回弹
自定义 modal 一般不在全屏任意位置滑动,而是限制在某个容器内。假设 modal 只能从顶部 0 移动到容器底部减去自身高度,那么所有目标位置都要经过边界收拢。跟手阶段可以完全跟随手指,但惯性目标必须先调用 clampTarget,否则快速滑动后 modal 会冲出可视区域。
clampTarget(target) {
const maxY = this.data.containerHeight - this.data.modalHeight;
return Math.max(0, Math.min(target, maxY));
}
如果拖拽过程中已经越过边界,直接使用线性跟手会让用户感觉很硬。常见做法是超过边界的那一段位移乘一个 0.3 左右的阻尼系数,使 modal 看起来像被边缘挡住。例如 moveY 大于最大位移时,只取 maxY + (moveY - maxY) * 0.3。这个值只影响跟手显示,不影响松手后的惯性目标计算。
回弹动画可以复用前面逐帧动画逻辑,只是目标位置变成边界值。用户越界松手后,惯性目标被 clamp 到边界,动画自然会朝着边界回弹。为了代码清晰,建议把动画函数抽象成 animateYTo(targetY, duration),跟手结束和回弹都调用它,内部负责清理旧定时器、缓动和结束回调。
四、WXS 降低逻辑层通信开销
如果每次 touchmove 都通过 setData 把新坐标发到渲染层,低端设备上可能出现拖拽不跟手。微信小程序的 WXS 可以在渲染层直接响应触摸事件并修改节点样式,省去逻辑层往返。做法是把触摸记录、速度队列和基础位移计算放到 WXS 中,松手后再把末速度通过 callMethod 回传逻辑层。
下面是一个简化版 WXS 模块,它只负责跟手位移和末速度统计:
var state = {
startY: 0,
lastY: 0,
lastTime: 0,
velocities: []
};
function touchstart(event, ownerInstance) {
var touch = event.touches[0];
state.startY = touch.clientY;
state.lastY = touch.clientY;
state.lastTime = Date.now();
state.velocities = [];
}
function touchmove(event, ownerInstance) {
var touch = event.touches[0];
var now = Date.now();
var dt = now - state.lastTime;
if (dt > 0) {
var velocity = (touch.clientY - state.lastY) / dt;
state.velocities.push(velocity);
if (state.velocities.length > 5) {
state.velocities.shift();
}
}
state.lastY = touch.clientY;
state.lastTime = now;
var offsetY = touch.clientY - state.startY;
ownerInstance.selectComponent('.modal').setStyle({
transform: 'translateY(' + offsetY + 'px)'
});
}
function touchend(event, ownerInstance) {
var sum = 0;
for (var i = 0; i < state.velocities.length; i++) {
sum += state.velocities[i];
}
var velocity = state.velocities.length ? sum / state.velocities.length : 0;
ownerInstance.callMethod('onInertiaStart', { velocity: velocity });
}
module.exports = {
touchstart: touchstart,
touchmove: touchmove,
touchend: touchend
};
回到逻辑层后,onInertiaStart 接收速度,再走前面的距离计算公式和 JS 动画。这样高频的跟手操作留在渲染层,低频的惯性启动和参数计算留在逻辑层,整体响应会顺畅很多。如果项目对兼容性要求较高,也可以继续使用 setData 方案,但需要控制每帧更新的数据量,只更新 modal 的 transform 属性,避免整页重绘。
整体来看,惯性滑动并不是某个孤立参数,而是速度采样、减速模型、边界策略和渲染方式共同作用的结果。先把末速度算准,再选择一个合适的减速度,最后用 clamp 和回弹兜底,自定义 modal 在松手后的滑动就会接近原生拖拽体验。