做小程序自定义弹窗时,拖拽功能看起来简单,实际写起来坑不少。最常见的问题是:弹窗可以被拖出屏幕外找不回来、松手后弹窗瞬间“冻住”没有任何过渡、或者回弹动画卡顿掉帧。这篇文章把拖拽边界检测和惯性动画这两块拆开讲清楚,并给出可以直接跑的完整代码。

一、拖拽基础:三个触摸事件如何配合
小程序里的拖拽靠bindtouchstart、bindtouchmove、bindtouchend三个事件协作完成。整体思路是:按下时记录起始坐标和弹窗当前偏移量,移动时计算差值并更新弹窗位置,松手时决定是回弹还是惯性滑行。
先看数据结构。我们在data里维护四个关键变量:弹窗的translateX、translateY偏移量,以及按下时的手指坐标和弹窗初始偏移。很多教程直接用view的left/top属性来改位置,这样写每帧都会触发重排,性能很差。正确做法是用transform,配合CSS的transition做动画,GPU渲染,帧率稳定。
// modal.wxml
<view class="modal-mask" bindtap="onClose">
<view class="modal-box"
style="transform: translate({{translateX}}px, {{translateY}}px); transition: {{animating ? 'transform 0.3s ease-out' : 'none'}};"
catchtouchmove="onTouchMove"
bindtouchstart="onTouchStart"
bindtouchend="onTouchEnd">
<view class="modal-header">拖我试试</view>
<view class="modal-body">内容区域</view>
</view>
</view>
注意这里给弹窗容器用了catchtouchmove而不是bindtouchmove。catch会阻止事件冒泡,防止拖拽弹窗时整个页面跟着滚动,这是自定义modal最常见的体验问题之一。
二、边界检测:别让弹窗被拖出屏幕
边界检测的核心是在touchmove回调里对计算出的新偏移量做钳制(clamp)。需要先拿到两个信息:屏幕尺寸和弹窗自身尺寸。屏幕尺寸用wx.getSystemInfoSync()获取(新版本建议用wx.getWindowInfo),弹窗尺寸可以通过SelectorQuery在组件渲染完成后查询。
假设弹窗宽200px高300px,屏幕宽375px高667px,那么X方向的合法偏移范围就是-87.5到87.5(以弹窗初始居中位置为原点)。超出这个范围,弹窗至少要有一半留在屏幕内,或者干脆要求完全不能出去,看产品需求。下面是touchmove的完整实现:
onTouchStart(e) {
const touch = e.touches[0];
this.startX = touch.clientX;
this.startY = touch.clientY;
this.originX = this.data.translateX;
this.originY = this.data.translateY;
this.setData({ animating: false }); // 拖拽期间关闭过渡
},
onTouchMove(e) {
const touch = e.touches[0];
let dx = this.originX + (touch.clientX - this.startX);
let dy = this.originY + (touch.clientY - this.startY);
// 边界钳制:弹窗至少留一半在屏幕内
const maxX = this.windowWidth / 2;
const maxY = this.windowHeight / 2;
dx = Math.max(-maxX, Math.min(maxX, dx));
dy = Math.max(-maxY, Math.min(maxY, dy));
this.setData({ translateX: dx, translateY: dy });
},
这里有个性能细节值得注意:touchmove触发频率很高,每次setData都有通信开销。如果发现真机上拖拽跟手性差,可以只在偏移量变化超过一定阈值时才setData,或者直接通过this.animate接口操作节点,绕过数据层。另外,想要“拖到边缘时有阻尼感”,可以把超出边界部分的位移乘一个0.3的系数,用户会感觉弹窗被橡皮筋拽住,体验更接近原生。
三、惯性动画:根据松手速度计算滑行距离
松手瞬间弹窗停在原地,是自绘modal最廉价的实现,也是最生硬的。要做出惯性,关键是拿到松手时的手指速度。做法很简单:在touchmove里持续记录最近几次移动的坐标和时间戳,松手时用最后一段的位移除以时间得到速度(px/ms)。
惯性滑行的物理模型可以简化为:初速度乘以衰减系数。经验上一个不错的公式是distance = velocity * 220,速度上限做一下钳制(比如2px/ms),避免用力一甩弹窗飞出屏幕。算出惯性距离后,还要再做一次边界钳制,然后交给CSS transition执行动画。transition的时长可以按距离等比缩放,距离远动画长,距离短动画干脆,这样节奏才自然。
onTouchEnd(e) {
const v = this.computeVelocity(); // px/ms,带方向
let dx = this.data.translateX + v.x * 220;
let dy = this.data.translateY + v.y * 220;
// 惯性终点同样要做边界钳制
const maxX = this.windowWidth / 2;
const maxY = this.windowHeight / 2;
dx = Math.max(-maxX, Math.min(maxX, dx));
dy = Math.max(-maxY, Math.min(maxY, dy));
const distance = Math.hypot(dx - this.data.translateX, dy - this.data.translateY);
const duration = Math.min(600, Math.max(200, distance * 1.5));
this.setData({ animating: true, translateX: dx, translateY: dy });
// 动画结束后关闭transition,避免下次拖拽出现延迟
setTimeout(() => this.setData({ animating: false }), duration);
},
computeVelocity() {
const traces = this.traces || [];
if (traces.length < 2) return { x: 0, y: 0 };
const first = traces[0];
const last = traces[traces.length - 1];
const dt = last.t - first.t;
if (dt <= 0) return { x: 0, y: 0 };
return {
x: Math.max(-2, Math.min(2, (last.x - first.x) / dt)),
y: Math.max(-2, Math.min(2, (last.y - first.y) / dt))
};
},
记得在touchmove里维护this.traces数组,每次push{x, y, t: Date.now()}并只保留最近100ms内的记录,这样算出来的速度才代表松手瞬间的真实手势,而不是整个拖拽过程的平均速度——用平均速度做惯性是新手最容易犯的错,会导致慢慢松手时弹窗也猛地窜出去。
四、回弹效果与常见坑
如果产品要求“弹窗不能停在任意位置,松手后必须回到某个吸附点”,那惯性之后还要加一步回弹。实现方式是在惯性动画结束后再触发一次transition,把弹窗移到最近的吸附点(比如屏幕四角或中心)。吸附点的选择按距离最近原则计算即可,两段动画串起来用setTimeout衔接,或者监听transitionend事件更可靠。
几个实际项目中踩过的坑:一是安卓低端机上transition可能不触发transitionend,兜底方案是永远配一个setTimeout;二是弹窗内的输入框在拖拽时可能抢焦点,建议把拖拽手柄单独做成header区域,只在header上绑touch事件;三是如果modal是在page里而不是组件里实现,注意this.traces这类实例属性在页面隐藏时要清空,否则下次打开会残留脏数据。把这几处处理好,拖拽手感和原生应用基本看不出差别。