做过自定义modal的开发者大概都有过这样的体验:拖拽弹窗时跟随手指很顺畅,可一松手,弹窗要么像被冻住一样瞬间停死,要么一路滑到屏幕外才停下来。这两种极端体验的根源,往往不在于动画库选得好不好,而在于摩擦系数的设置方式。固定不变的摩擦系数无法适应不同的松手速度,只有根据拖拽速度动态调整摩擦系数,才能让弹窗在快甩时滑得远一些、慢拖时贴着手指停下,形成接近原生的手感。本文就来完整讲讲这套机制在小程序里的实现。

一、拖拽惯性的基本原理与速度计算
要动态调整摩擦系数,第一步得知道松手那一刻弹窗的运动速度是多少。小程序里通过三个触摸事件即可完成数据采集:在touchstart中记录起始坐标和时间戳,在touchmove中不断更新当前坐标,在touchend中用最后几帧的位移除以时间差算出速度。
这里有个容易被忽视的细节:不要只用首尾两点计算平均速度。用户的手指动作往往不是匀速的,最后那几十毫秒的速度才是决定惯性的关键。如果拖拽了三秒,中间有停顿,用平均速度算出来的结果会严重偏小,弹窗松手后几乎不动。比较稳妥的做法是维护一个小的采样队列,只保留最近几帧的数据。
Page({
data: {
modalLeft: 0,
modalTop: 0
},
touchState: {
startX: 0,
startY: 0,
lastX: 0,
lastY: 0,
samples: [], // 采样队列,保存 {x, y, t}
dragging: false
},
onTouchStart(e) {
const touch = e.touches[0];
this.touchState = {
startX: touch.clientX,
startY: touch.clientY,
lastX: touch.clientX,
lastY: touch.clientY,
samples: [{ x: touch.clientX, y: touch.clientY, t: Date.now() }],
dragging: true
};
},
onTouchMove(e) {
const touch = e.touches[0];
const s = this.touchState;
if (!s.dragging) return;
s.samples.push({ x: touch.clientX, y: touch.clientY, t: Date.now() });
// 只保留最近5帧,避免队列无限增长
if (s.samples.length > 5) s.samples.shift();
const dx = touch.clientX - s.startX;
const dy = touch.clientY - s.startY;
this.setData({
modalLeft: dx,
modalTop: dy
});
s.lastX = touch.clientX;
s.lastY = touch.clientY;
}
});采样帧数不宜太多也不宜太少。五帧在普通设备上大约对应几十毫秒到一百毫秒,既能滤掉瞬间抖动,又能反映松手前的真实速度。帧数取得太多,相当于又回到了平均速度的问题;取得太少,单帧的采样误差会被放大,速度值忽大忽小,最终表现就是摩擦系数跟着乱跳。
二、松手时估算速度并动态计算摩擦系数
速度估算的核心逻辑放在touchend中:从采样队列取出最早和最新两条记录,用位移除以时间差得到像素每毫秒的速度。要注意touchend的事件对象里touches是空数组,如果在这里再去读e.touches[0]会直接报错,必须依赖之前保存的采样数据。
拿到速度之后,就到了本文的核心:根据速度大小分段映射摩擦系数。物理直觉上,速度越快,摩擦衰减应该越弱一些,让弹窗多滑一段距离,模拟物体高速运动时惯性更明显的感受;速度慢时则用较强的摩擦,让弹窗迅速停下,避免拖泥带水。这种分段映射本质上是对真实物理的一种体验层面的近似,纯粹为手感服务。
onTouchEnd() {
const s = this.touchState;
s.dragging = false;
const samples = s.samples;
if (samples.length < 2) return;
const first = samples[0];
const last = samples[samples.length - 1];
const dt = last.t - first.t;
if (dt <= 0) return;
const vx = (last.x - first.x) / dt; // 像素/毫秒
const vy = (last.y - first.y) / dt;
const speed = Math.sqrt(vx * vx + vy * vy);
// 根据速度动态计算摩擦系数,速度越快摩擦越小
let friction;
if (speed < 0.3) {
// 慢速拖拽:强摩擦,基本原地停止
friction = 0.92;
} else if (speed < 1.0) {
// 中速:常规摩擦
friction = 0.95;
} else {
// 快速甩动:弱摩擦,滑得更远
friction = 0.975;
}
this.startInertia(vx, vy, friction);
}系数的具体数值需要结合帧率理解。惯性动画通常逐帧执行velocity = velocity * friction,friction为0.92时,速度每帧衰减百分之八,在60帧每秒的设备上大约半秒内就基本停止;0.975则要滑上两三秒。这些数字没有标准答案,建议在真机上以0.005为步长微调,不同的modal尺寸和内容密度下最佳值会有差别。也可以不做硬分段,而是用线性插值让friction随speed平滑变化,手感会更细腻,实现上只是把if-else换成一个分段函数而已。
三、用requestAnimationFrame驱动惯性动画并处理边界
算好摩擦系数后,惯性动画本身交给requestAnimationFrame驱动。小程序的页面和组件里可以直接调用这个API,每帧把速度乘以摩擦系数得到衰减后的速度,再叠加到位移上,最后通过setData更新modal位置。注意setData调用频率较高,传输的数据要尽量精简,只传变化的坐标字段。
startInertia(vx, vy, friction) {
const step = () => {
// 速度衰减
vx *= friction;
vy *= friction;
// 每帧约16ms,换算为位移
const dx = vx * 16;
const dy = vy * 16;
const left = this.data.modalLeft + dx;
const top = this.data.modalTop + dy;
// 边界约束,防止滑出屏幕
const maxX = 200;
const minX = -200;
const clampedLeft = Math.max(minX, Math.min(maxX, left));
this.setData({ modalLeft: clampedLeft, modalTop: top });
// 速度足够小或撞到边界就停止
if (Math.abs(vx) < 0.01 || clampedLeft !== left) {
wx.vibrateShort({ type: 'light' }); // 撞墙轻微震动反馈,可选
return;
}
this._rafId = requestAnimationFrame(step);
};
this._rafId = requestAnimationFrame(step);
},
onUnload() {
if (this._rafId) cancelAnimationFrame(this._rafId);
}边界处理是体验的分水岭。不做边界判断的话,用户一个快速甩动就能把modal送出屏幕再也找不回来。常见的做法有两种:一种是上面代码中的硬约束,越界直接截断并停止动画,配合一次轻震动反馈,手感干脆;另一种是回弹,越界后给一个反向的弹簧力把弹窗拉回来,观感更接近iOS原生,但实现复杂度更高,需要额外引入弹簧系数和阻尼参数。
还有几个实际踩坑点值得提醒。第一,低端安卓机的touchmove触发频率不稳定,采样时间差可能出现零,代码里必须做除零保护。第二,setData每帧调用在高频场景下可能掉帧,如果对流畅度要求高,可以考虑把modal放在movable-area和movable-view里,利用原生组件的渲染通道,但代价是失去对惯性曲线的完全控制。第三,如果modal内容里包含滚动区域,记得在拖拽手柄上设置catchtouchmove阻止事件冒泡,否则拖手柄和滚内容会互相打架。
整体来看,速度动态摩擦的核心就三步:采样估速、分段映射摩擦系数、逐帧衰减动画。代码量不大,但对细节的敏感度要求不低,多在真机上对比不同系数下的手感差异,才能调出那个让人挑不出毛病的阻尼效果。