自定义 modal 在微信小程序里经常被用作仿原生弹窗、底部操作面板或悬浮工具入口。为了让用户能拖动标题栏改变位置,开发者通常会监听 touchstart 和 touchmove,再通过 setData 更新节点的 left 和 top。逻辑本身不复杂,但测试时很容易出现一个情况:往上拖动后松手,弹窗头部卡进状态栏,或者往右拖之后右边部分跑到屏幕外,想拖回来只能靠盲操作。这个问题不是手势事件失效,而是缺少拖拽边界检测,即没有把可移动坐标限制在屏幕可视区间内。配合微信小程序提供的 getSystemInfo 系列接口读取屏幕宽高,再结合节点自身尺寸,就能准确计算 left 和 top 的可取值区间。

一、先理解拖拽坐标与越界原因
自定义 modal 的拖拽通常通过行内样式实现。比如给弹窗节点设置 style="left: {{left}}px; top: {{top}}px;",然后在 touchmove 中不断修改 left 和 top。小程序的页面坐标系原点在页面左上角,向左或向上移动时坐标会变成负数。屏幕可视区域的范围则是从坐标 (0, 0) 到 (windowWidth, windowHeight)。如果拖拽过程中不限制取值范围,手指向上滑动时 top 很容易小于 0,弹窗顶端就会进入状态栏或直接消失;手指向右滑动时 left 可能超过「屏幕宽度减去弹窗宽度」这个临界值,弹窗右侧就会跑到屏幕外。
有些开发者会想到用小程序自带的 movable-view 组件。movable-view 本身可以配合 movable-area 限制范围,但它的可定制度有限,比如拖拽手柄样式、边缘阻尼手感、拖动结束回调位置等不一定满足设计需求,因此不少场景还是选择用普通 view 自己实现拖拽。一旦自己写拖拽逻辑,边界检测就不能省略。另一个容易被忽略的问题是:页面本身可能发生滚动。touchmove 事件里的 clientX 和 clientY 是相对当前视口的坐标,而 left 和 top 如果作用在 position: absolute 的节点上,它们是相对页面文档的坐标。页面滚动后这两套坐标会出现偏移。所以用于自由拖拽的浮层,更推荐使用 position: fixed 定位,让节点脱离文档流,这样 clientX 与 left、clientY 与 top 的对应关系才稳定。
一个基础的自定义 modal 结构大致如下,拖拽手柄放在弹窗头部,遮罩层负责关闭弹窗。
<view class="page">
<view class="mask" wx:if="{{showModal}}" bindtap="closeModal"></view>
<view
id="custom-modal"
class="custom-modal"
style="left: {{left}}px; top: {{top}}px;"
bindtouchstart="onTouchStart"
bindtouchmove="onTouchMove"
bindtouchend="onTouchEnd"
>
<view class="modal-header">按住这里拖动</view>
<view class="modal-body">这是弹窗内容</view>
</view>
</view>
弹窗的样式里需要明确设置宽高,并且建议使用 fixed 定位。宽度和高度最好写成固定 px,或者通过 JS 动态读取。如果使用 rpx,节点实际宽高会被转换成不同屏幕上的 px 值,后续计算边界时要保持单位统一,避免把 rpx 数值直接和 screenWidth 相减。
.custom-modal {
position: fixed;
width: 300px;
height: 200px;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
z-index: 999;
}
二、用 getSystemInfo 获取屏幕宽高并计算可移动范围
边界检测的第一步是知道屏幕可视区域的宽高。微信小程序提供了 wx.getSystemInfo 接口,它可以返回 windowWidth、windowHeight、screenWidth、screenHeight、pixelRatio 等字段。其中 windowWidth 和 windowHeight 是当前窗口可视区域的 px 宽高,不包含系统状态栏高度;screenWidth 和 screenHeight 是整个物理屏幕的 px 宽高。拖拽边界检测应该使用 window 相关字段,而不是 screen 相关字段,因为 modal 是在小程序窗口内移动,不是在整个物理屏幕上移动。
随着基础库升级,官方已经推荐使用 wx.getWindowInfo 替代 wx.getSystemInfo 的同步调用。前者的返回结构和 wx.getSystemInfoSync 基本一致,但字段更加聚焦,性能也更好。低版本基础库仍然可以使用 wx.getSystemInfoSync 做兼容。实际开发中可以先判断 wx.getWindowInfo 是否存在,存在就调用它,否则回退到旧接口。获取到窗口宽高后,还不能直接拿它们作为 left 和 top 的最大值。假设屏幕宽 375px,modal 宽 300px,那么 left 最大只能是 75px,因为 left 是弹窗左上角的坐标,弹窗右边缘的位置等于 left 加模态宽度。同理,top 的最大值是窗口高度减去弹窗高度。全面屏设备底部还有安全区域,如果希望弹窗不遮挡底部横条,需要再减去底部安全区高度。顶部有自定义导航栏时,也可以把顶部最小 top 调整到导航栏底边以下。
要拿到 modal 的真实宽高,不能直接写死。不同机型上 rpx 转换后的 px 值可能不同,使用 createSelectorQuery 获取节点信息更稳妥。下面这段代码在 onReady 中执行,先读取窗口信息,再查询节点实际宽高,最后计算出 left 和 top 的合法边界。注意顶部安全区顶部 safeArea.top 通常是状态栏高度,如果页面不是沉浸式导航栏,也可以直接把 minTop 设为 0。
Page({
data: {
showModal: true,
left: 20,
top: 120,
modalWidth: 300,
modalHeight: 200,
minLeft: 0,
maxLeft: 100,
minTop: 0,
maxTop: 400
},
onReady() {
this.initBounds();
},
initBounds() {
const windowInfo = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync();
const screenWidth = windowInfo.windowWidth;
const screenHeight = windowInfo.windowHeight;
this.createSelectorQuery()
.select('#custom-modal')
.boundingClientRect((rect) => {
const modalWidth = rect.width;
const modalHeight = rect.height;
const topSafe = windowInfo.safeArea ? windowInfo.safeArea.top : 0;
const bottomSafe = windowInfo.safeArea ? (screenHeight - windowInfo.safeArea.bottom) : 0;
this.setData({
modalWidth,
modalHeight,
minLeft: 0,
maxLeft: screenWidth - modalWidth,
minTop: topSafe,
maxTop: screenHeight - modalHeight - bottomSafe
});
})
.exec();
}
});
上面的 maxLeft 直接按屏幕窗口宽度减去弹窗宽度计算。如果设计上希望弹窗可以部分拉出屏幕边缘再回弹,可以额外设置一个缓冲值,但默认产品需求下,弹窗应当完整落在窗口内。安全区的处理方式也可以简化:如果页面已经通过 safe-area-inset-bottom 留出了底部空间,这里 bottomSafe 可以设置为 0。
三、在 touchmove 中实现边界修正
拖拽过程中,每次 touchmove 都会带来一个新的手指位置。比较稳定的做法是:在 touchstart 时记录手指起始坐标 clientX 和 clientY,同时记录当前弹窗的 left 和 top。touchmove 中先计算出相对于起始点的位移量,再用起始 left 和 top 加上这个位移量,得到目标 left 和 top。最后把这个目标值限定在 min 和 max 区间内,并写入 data。边界修正的核心就是 Math.min 和 Math.max 的组合:先把当前值与最小值比较取较大者,再把结果与最大值比较取较小者。这样可以直接把越界坐标拉回合法区间。
下面给出完整的页面逻辑。拖拽开始前最好检查一下是否已经完成边界初始化,如果节点尺寸还没拿到,可以先调用 initBounds 再继续。否则第一次拖拽可能因为 min 和 max 还是初始占位值,导致边界不准确。代码中还在 touchmove 里对频繁 setData 做了简单的控制,但这里为了保持位置跟手,仍然每次移动都更新。实际项目中如果拖动造成明显卡顿,可以考虑使用 setData 传入路径更新,或者换用 transform 来移动节点。
Page({
data: {
showModal: true,
left: 20,
top: 120,
modalWidth: 300,
modalHeight: 200,
minLeft: 0,
maxLeft: 100,
minTop: 0,
maxTop: 400
},
onReady() {
this.initBounds();
},
initBounds() {
const windowInfo = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync();
const screenWidth = windowInfo.windowWidth;
const screenHeight = windowInfo.windowHeight;
this.createSelectorQuery()
.select('#custom-modal')
.boundingClientRect((rect) => {
const modalWidth = rect.width;
const modalHeight = rect.height;
const topSafe = windowInfo.safeArea ? windowInfo.safeArea.top : 0;
const bottomSafe = windowInfo.safeArea ? (screenHeight - windowInfo.safeArea.bottom) : 0;
this.setData({
modalWidth,
modalHeight,
minLeft: 0,
maxLeft: screenWidth - modalWidth,
minTop: topSafe,
maxTop: screenHeight - modalHeight - bottomSafe
});
})
.exec();
},
onTouchStart(e) {
const touch = e.touches[0];
this.dragStart = {
x: touch.clientX,
y: touch.clientY,
left: this.data.left,
top: this.data.top
};
},
onTouchMove(e) {
if (!this.dragStart) return;
const touch = e.touches[0];
const newLeft = this.dragStart.left + (touch.clientX - this.dragStart.x);
const newTop = this.dragStart.top + (touch.clientY - this.dragStart.y);
const clamped = this.clampPosition(newLeft, newTop);
this.setData({
left: clamped.left,
top: clamped.top
});
},
onTouchEnd() {
this.dragStart = null;
},
clampPosition(left, top) {
const { minLeft, maxLeft, minTop, maxTop } = this.data;
return {
left: Math.min(Math.max(left, minLeft), maxLeft),
top: Math.min(Math.max(top, minTop), maxTop)
};
}
});
这段代码可以直接放在页面 JS 中运行,前提是 WXML 中对应的节点已经绑定 bindtouchstart、bindtouchmove 和 bindtouchend。从代码可以看出,真正实现边界检测的只是 clampPosition 里的四行数学计算,但它能避免大量关于弹窗飞出屏幕的用户投诉。拖拽结束后不需要额外设置位置,因为 touchmove 中每次写入的 left 和 top 都已经处于合法范围内。
四、真机适配与常见坑
真机上最容易出现的是单位不一致问题。CSS 中的 left 和 top 可以写 px,也可以写 rpx。小程序编译后 rpx 会按照屏幕宽度换算成 px,但 JS 调用 getSystemInfo 拿到的是 px。比如设计稿上 modal 宽 600rpx,在 iPhone 12 上实际是 300px,在部分安卓设备上可能是 340px。如果 JS 里直接用 rpx 数值去计算边界,就会产生明显的误差。因此推荐在样式中为弹窗固定 px 宽高,或者通过 createSelectorQuery 读取真实像素宽高来参与计算。不要再手动猜测节点尺寸。
另一个坑是页面滚动。前面提到 touchmove 返回的是相对视口的坐标,但如果弹窗使用 absolute 定位,页面滚动后视口坐标与文档坐标就会错位。一个常见的复现路径是:页面内容比较长,用户滑到页面底部后打开弹窗,再拖拽弹窗,会发现弹窗位置飞到了页面顶部。解决办法就是给 modal 使用 fixed 定位。fixed 定位的 left 和 top 相对于视口,正好和 clientX、clientY 对齐。如果项目必须使用 absolute 定位,那就要在 touchstart 时记录当前页面的 scrollTop,在计算 newTop 时加上滚动偏移量,同时注意页面滚动可能触发 modal 位置更新。这样复杂度会明显升高,因此一般的悬浮拖拽弹窗都建议用 fixed。
安全区域也需要留意。iPhone 全面屏底部有一条手势横条,如果弹窗可以拖到窗口最底部,底部内容可能被横条遮挡。此时应该读取 safeArea.bottom,将 maxTop 设置为窗口高度减去弹窗高度再减去底部安全区高度。部分 Android 设备没有 safeArea 字段,可以做兼容判断,不存在时直接置为 0。另外,自定义导航栏的小程序页面顶部安全区往往要保留状态栏高度,否则弹窗拖到最顶部后标题栏会和系统时间重叠。可以通过 safeArea.top 设置 minTop,或者根据导航栏实际高度手动指定。
还有一个性能层面的细节。频繁拖拽时如果每次 setData 都传递整个对象,可能会触发较多次数渲染。这个 Demo 中数据量小,问题不明显,但若弹窗内部还有富文本、图片列表等内容,拖拽时就要考虑减少 setData 的字段范围,或者使用 transform 替代 left 和 top。使用 transform 时,边界检测逻辑不变,只需要记住基础 left 和 top,位移量用 transform 的 translate 来表达。最终落地时再统一合并。这样做会增加一些代码量,但拖动过程不会引起重排,性能更好。
最后,如果项目还在使用 wx.getSystemInfoSync,建议逐步替换为 wx.getWindowInfo。微信官方已经标记部分同步接口为不再推荐使用,虽然短期内不会移除,但新接口返回的字段更符合当前屏幕适配策略。做兼容时可以使用类似 wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync() 的写法,让老版本基础库和新版本基础库都能正常执行。
微信小程序getSystemInfo拖拽边界检测修改时间:2026-09-23 12:02:41