做App的时候突然来了一个需求:要在页面上做一个可以随便拖动的悬浮球,点一下能弹出一个菜单,拖到屏幕边缘还要有一点吸附效果。第一反应这有什么难的,不就是touchmove换一下left和top嘛。结果真正写起来,发现H5、微信小程序、App端各有各的脾气。H5上很流畅,小程序上拖到边界就卡顿,App端又冒出了新的坐标系问题。
我最后用四百多行代码搞定了这个组件,过程中踩了无数坑。今天把实现思路和关键细节全部分享出来,希望你们别走我的老路。文章不贴全量代码,只讲核心逻辑和不同端的适配,因为完整组件已经放到项目里,你照着推也能写出来。
一、先设计基本的组件结构
不考虑业务,悬浮球本质就是一个view,位置通过绝对定位控制,手指拖动时改变left和top。难点在于不同端的事件模型不太一样。我先用最小demo验证一下可行性。
touch事件在H5和小程序都支持,但App端(尤其是nvue)有差异。我选择全用vue页面而不是nvue,因为nvue里必须用app-plus手势系统,太麻烦。
二、记录触摸起始位置和球的初始位置
这是拖拽的基础。在touchstart里记录触点相对于屏幕的坐标,以及此时球的left和top。
methods: {
onTouchStart(e) {
const touch = e.touches[0];
this.startX = touch.clientX;
this.startY = touch.clientY;
this.startLeft = this.ballLeft;
this.startTop = this.ballTop;
this.dragging = true;
},
}
注意这里不要用e.changedTouches,因为start阶段可能没值。clientX在小程序和H5都是相对于视口的坐标,与CSS的定位坐标一致。
可是App端(非nvue)的vue页面上touch事件也是标准HTML5的,所以clientX同样可用。但如果你的项目编译到App的vue页面,默认是weex渲染?不对,uni-app的App端vue页面默认使用webview渲染,所以touch事件行为跟H5基本一致。因此这套逻辑可以直接跨App端。
三、move里计算位置并限制边界
touchmove时,用当前触点坐标减去起始坐标,加上球的初始位置,就能求出新的left和top。不直接设置clientX是为了避免手指移动时球突然跳到手指位置。
onTouchMove(e) {
if (!this.dragging) return;
const touch = e.touches[0];
let newLeft = this.startLeft + (touch.clientX - this.startX);
let newTop = this.startTop + (touch.clientY - this.startY);
// 边界限制,防止球被拖到屏幕外面
const windowWidth = uni.getSystemInfoSync().windowWidth;
const windowHeight = uni.getSystemInfoSync().windowHeight;
const ballSize = this.size; // 球的直径,比如 80
newLeft = Math.max(0, Math.min(newLeft, windowWidth - ballSize));
newTop = Math.max(0, Math.min(newTop, windowHeight - ballSize));
this.ballLeft = newLeft;
this.ballTop = newTop;
},
这里有个问题:uni.getSystemInfoSync()在touchmove中频繁调用性能不好。我在mounted时缓存好尺寸,避免重复获取。
四、边界吸附:让用户体验更顺滑
基本拖拽能工作后,产品又提了吸附效果:手指松开时,如果球在屏幕左半边就自动滑到左边,在右半边就滑到右边,并且不能超出顶部和底部。
我选择在touchend里做一个简单的线性动画,而不是用CSS过渡。因为CSS过渡在原生渲染中有时会被touchmove打断,而且我们不能直接用改变left和top来过渡(没有transition)。最简单的方式是uni.createAnimation?但那个要绑定到style的animation数据,比较麻烦。
更省事的方法:在touchend里用setInterval或requestAnimationFrame手动做动画,但要注意小程序里没有requestAnimationFrame。所以最好用setInterval。我写了一个简单的数组动画:
onTouchEnd() {
this.dragging = false;
const ballSize = this.size;
const windowWidth = uni.getSystemInfoSync().windowWidth;
const middle = (windowWidth - ballSize) / 2;
const targetX = this.ballLeft < middle ? 0 : (windowWidth - ballSize);
const targetY = this.ballTop;
// 简单吸附到左右边缘,Y保持不变
this.animateTo(targetX, targetY);
},
methods: {
animateTo(targetX, targetY) {
const step = 8; // 每帧移动的像素
const dx = targetX - this.ballLeft;
const dy = targetY - this.ballTop;
const distance = Math.sqrt(dx*dx + dy*dy);
if (distance < step) {
this.ballLeft = targetX;
this.ballTop = targetY;
return;
}
const ratio = step / distance;
this.ballLeft += dx * ratio;
this.ballTop += dy * ratio;
setTimeout(() => this.animateTo(targetX, targetY), 16);
},
}
注意这里用setTimeout递归,要防止组件被销毁后还在动画。我加了一个this._destroyed标志,在beforeDestroy里置为true,动画里判断一下。
五、点击事件和拖拽的冲突
悬浮球肯定要能点击,但是手指稍微一动就会触发click,导致点一下没反应或者误触。解决办法是判断从touchstart到touchend之间移动的距离,如果小于5px就视为点击,否则不抛出点击事件。
onTouchEnd(e) {
const touch = e.changedTouches[0];
const deltaX = touch.clientX - this.startX;
const deltaY = touch.clientY - this.startY;
if (Math.sqrt(deltaX*deltaX + deltaY*deltaY) < 5) {
this.$emit('click');
}
// 继续执行吸附逻辑
this.dragging = false;
// ...
},
这样一来,H5和小程序都能区分点击和拖拽。但要注意,在小程序里如果view上有绑定@click事件,它会在touchend之后触发,与我们的click手动emit会重复。所以我把@click事件移除,全部用手动触发自定义事件。
六、小程序端的特殊问题:bindtouchmove和catchtouchmove
在微信小程序中,如果悬浮球放在页面里,touchmove事件默认可能被滚动视图捕获,导致页面跟着滚动。解决方法是给view加上catch:touchmove而不是bind:touchmove。在uni-app里你直接写@touchmove.stop.prevent试试,但uni-app官方文档说catch和stop只在某些平台生效。
我最终采用条件编译的方式:在小程序端单独写一个事件绑定?太难看了。实际上@touchmove.stop.prevent在小程序中会变成catchtouchmove,这样就能阻止滚动穿透。
注意.stop.prevent在H5上会阻止默认行为,避免拖拽时触发页面滚动或图片拖动。在小程序上也会转换为catch。实测效果不错。但如果你只想让悬浮球覆盖在原生组件(如map、video)上,那光有catch不行,原生组件层级太高,需要cover-view或者把悬浮球做成原生组件。这个坑我留在后面说。
七、nvue专用?不,还是绕开吧
如果项目里某个页面用了nvue渲染,那不能直接使用touch事件,因为nvue的bubbles、composed这些属性很麻烦。我在nvue页面里尝试过直接用touchmove,结果发现触摸事件被bind后不触发。后来查资料才知道,nvue的touch事件需要配合@touchmove.native或者使用movable-area组件。所以我决定在nvue页面里改用官方提供的movable-area和movable-view,它们本身支持拖拽,而且性能好。
但我这个组件为了统一,我直接在nvue页面里不生效。所以如果你必须在nvue里用,就单独写一个nvue版本,基于movable-view。虽然多花点功夫,但总比跟手势库搏斗舒服。
八、在App端遇到的一个怪异坐标偏移
App端的vue页面默认webview渲染,一般和H5一致。但如果你开了uni-app 原生导航栏,并且页面有自定义导航栏,触摸事件的clientY可能包含状态栏高度,导致球跳动。我解决方法是获取系统的statusBarHeight和uni.upx2px(44)作为导航栏高度,在touchstart里的startY减去这个偏移?其实更准确的逻辑是,你球是用position:fixed定位的,而touch.clientY相对于视口,所以不需要减偏移。但如果你的球放在一个非fixed的容器里,坐标就会错。
所以最佳实践是让悬浮球直接挂到页面根节点,并且使用position:fixed。这样clientX/clientY和fixed定位的坐标都是相对于视口的,完全一致。
.float-ball {
position: fixed;
z-index: 999;
}
在H5上fixed没问题,小程序fixed也是相对于视口,App内webview也同样。这就避免了坐标换算的麻烦。
九、真机上的卡顿优化
在微信小程序真机上,悬浮球拖拽偶尔会卡顿,特别是加上了一些阴影和渐变样式。原因是每次touchmove都触发setData去更新left/top,而setData是异步且通过native bridge通信,频繁调用必然卡顿。
简单的优化方法:不使用CSS变量或者内联style,而是直接操作DOM?小程序里你没法操作。另一个方法是在H5端使用style绑定,在小程序端减少setData频率。常见处理是做一个“节流”,在touchmove中不要每次都setData,比如每16ms更新一次。
onTouchMove(e) {
// 计算新位置
const touch = e.touches[0];
let newLeft = this.startLeft + (touch.clientX - this.startX);
let newTop = this.startTop + (touch.clientY - this.startY);
// 省略边界判断
const now = Date.now();
if (now - this.lastMoveTime > 16) {
this.ballLeft = newLeft;
this.ballTop = newTop;
this.lastMoveTime = now;
}
},
但这样滑动起来会有轻微掉帧。更好的方式是在小程序里使用worklet?那太高级了。实际上对于只有一个view的悬浮球,生产环境卡顿并不明显。如果你真的遇到很卡,可以试试把悬浮球做成canvas绘制?没必要,保持组件简单为好。
十、吸边动画与页面滚动的纠缠
悬浮球fixed定位后,页面滚动时它会跟着视口移动,这符合预期。但吸边动画时,如果用户又触摸了球,应该立即中断动画,转而开始新的拖拽。所以我在touchstart里清掉所有定时器。
methods: {
onTouchStart(e) {
this._clearAnimation();
// ...省略
},
_clearAnimation() {
if (this._animTimer) {
clearTimeout(this._animTimer);
this._animTimer = null;
}
},
}
同时要注意,在动画过程中如果组件被销毁,setTimeout递归会访问不存在的变量,所以要加一个this._cleared标记,在beforeDestroy里设true,并且在递归前判断。
十一、如果要支持多个悬浮球
我们只需要同一个页面最多一个悬浮球。但如果想支持多个,每个组件实例需要自己的定位标识,避免冲突。其实只要每个浮球都使用position:fixed,都定位在视口内,它们可以共存。但多个球之间的层级管理容易混乱,建议只保留一个。
另外,如果你有全局悬浮球(跨页面),通常的做法是封装成uni-app插件,在App.vue里挂载一个全局组件。但那种悬浮球需要控制页面切换后的显示和隐藏,还要处理tabBar二级页面等,更复杂。这里就不展开了。
十二、组件最终接口设计
我对外暴露了几个属性:size(球直径)、initLeft、initTop、吸附开关等;一个事件:click。这样使用起来很方便。
菜单
在组件内部,我watch initLeft和initTop,初始化时设置一次位置。注意不要把它们变成响应式导致每次变化都重设位置,因为拖拽过程中会修改ballLeft,但initLeft不应该影响组件状态。
组件样式里我设置了user-select:none,避免拖拽时选中文本。在小程序里可以用-webkit-user-select:none。
.float-ball {
user-select: none;
-webkit-user-select: none;
touch-action: none;
}
touch-action: none在H5上很重要,否则浏览器会认为你在进行滚动或缩放。小程序里不受影响。
十三、关于覆盖原生组件的难题
如果你在页面里用了map、video或者canvas(原生组件),在微信小程序里这些原生组件的层级永远高于普通view。因此悬浮球会跑到下面去,根本拖不了。解决办法有:
- 把悬浮球也用原生组件实现,但太费劲。
- 用官方提供的
cover-view和cover-image实现悬浮球,但cover-view的限制很多(不支持的css属性、不可嵌套某些标签)而且不支持复杂的touchmove?实际上cover-view支持touch事件,但你没办法在cover-view上使用动态的left/top进行拖拽?可以,还是position:fixed。 - 更通用的方案:避免在需要覆盖原生组件的场景下使用悬浮球,或者用subNVue(App端)代替。小程序端没有完美方案。
由于我的项目里悬浮球主要出现在普通列表页面,没有覆盖原生组件,所以我没做特殊处理。但如果你的业务正好需要,那只能单独写一个原生cover-view版本,我建议还是让产品取消这个需求吧。
十四、一番折腾后的总结
可拖拽悬浮球的难点不在于拖拽逻辑,而在于各端的一致性。如果你只用H5测试,会觉得很顺利。但发布到小程序和App后,各种意外接踵而至。我的最终建议是:
- 使用position:fixed定位,不要用absolute。
- 使用
@touchmove.stop.prevent阻止滚动穿透。 - 缓存窗口尺寸,不要在touchmove里调用getSystemInfo。
- 点击和拖拽通过位移阈值区分。
- 吸边动画用setInterval,但要处理销毁和打断。
- 在nvue里就放弃用这个组件,换成movable-view重写。
我把这套逻辑封装成了uni_modules形式,在项目里npm安装就能用。写完之后再回头看,其实能踩的坑也就是这几个。分享出来,主要还是希望你们不用再为“为什么小程序上拖不动”而翻几十篇博客。

