接了个IM项目,需要在聊天右上角拖一个“小铃铛”当作快捷入口。需求说得很轻巧:“就是和苹果那种悬浮辅助球一样嘛。”结果被现实狠狠抽了一巴掌。前后拖了3天,在H5、微信小程序、安卓App上分别出现不同毛病。好在最后沉淀出了一个完整可用的 FloatBall 组件,今天拿出来聊聊思路和坑。
一、别急着上手拖拽,先定义“双向交互”
最容易被忽略的是:用户拖完球之后,到底算不算是点击?咱们业务里的小铃铛点击会弹开发送消息面板,如果拖完也触发这个面板,用户在满屏找位置关闭时会很崩溃。
所以我第一时间给组件划了三个行为: touchstart 记录起点、touchmove 按偏移位移、touchend 根据偏移量决定是否触发 click。 如果位移大于4px,就说明用户想调整位置而不是点按,click事件必须手动拦掉。
二、第一版代码问题很大,先暴露草稿
// FloatBall.vue - 只关注拖拽逻辑的一段草稿
<template>
<view class="float-ball" :style="{ left: pos.x + 'px', top: pos.y + 'px' }"
@touchstart="onStart" @touchmove="onMove" @touchend="onEnd">
<slot />
</view>
</template>
<script setup>
import { reactive } from 'vue';
const props = defineProps({
initialX: { type: Number, default: 280 },
initialY: { type: Number, default: 300 }
});
const emit = defineEmits(['tap', 'dragend']);
const pos = reactive({ x: props.initialX, y: props.initialY });
let startX = 0;
let startY = 0;
let originX = 0;
let originY = 0;
let moved = false;
function onStart(e) {
const touch = e.touches[0] || e.changedTouches[0];
startX = touch.pageX;
startY = touch.pageY;
originX = pos.x;
originY = pos.y;
moved = false;
}
function onMove(e) {
const touch = e.touches[0] || e.changedTouches[0];
const dx = touch.pageX - startX;
const dy = touch.pageY - startY;
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) {
moved = true;
}
// 实时更新位置
let nextX = originX + dx;
let nextY = originY + dy;
// 边界大约用窗口尺寸减去100,后面再细化
const sys = uni.getSystemInfoSync();
nextX = Math.min(Math.max(0, nextX), sys.windowWidth - 100);
nextY = Math.min(Math.max(0, nextY), sys.windowHeight - 100);
pos.x = nextX;
pos.y = nextY;
}
function onEnd(e) {
if (moved) {
emit('dragend', { x: pos.x, y: pos.y });
// 如果已经drag过,就不派发 tap
return;
}
emit('tap');
}
</script>
这段代码放H5上没问题,但一编译到微信小程序,诡异的事情来了:用手指轻轻点按,球会“飘”一下再弹回来,然后 click 照样不触发。后来我才反应过来——小程序端 touchstart 的坐标和 H5 的 pageX 并没有问题,真正的问题出在 @touchmove 默认会引发页面滚动,而我的组件并不是 fixed 定死,手指一动,页面也跟着滚,于是坐标全乱。
三、给每端加一些“私货”才稳定
想要在大多数端上跑稳,必须做三件事:阻止 touchmove 的默认行为、把位置改成 fixed 定位、在小程序端使用 catch:touchmove。
其中小程序端为了阻止冒泡,不能用 @touchmove.stop.prevent,得用 @touchmove.stop。但 Vue3 编译成 wxml 之后,处理方式还会更奇怪。我干脆把外层 view 换成 movable-area ?后来发现用官方 movable-view 更加省事。
四、可拖拽“官方方案”加自定义业务,终于顺滑了
与其自己跟 touch 事件死磕,不如结合 movable-area 和 movable-view 的底层能力。它天生支持拖动、边界限制,并且在微信小程序上性能极好。我们不需要重复造轮子,只要在外面再包一层,用来区分点击和拖动。
于是重构成这样,代码清爽了很多:
<template>
<movable-area class="float-area" :style="{ width: areaWidth + 'px', height: areaHeight + 'px' }">
<movable-view
class="float-ball"
direction="all"
:x="ballX"
:y="ballY"
:out-of-bounds="false"
:damping="20"
@touchstart="onTouchStart"
@touchend="onTouchEnd"
@change="onChange"
>
<view class="ball-inner">
<slot />
</view>
</movable-view>
</movable-area>
</template>
<script setup>
import { ref, onMounted } from 'vue';
const props = defineProps({
initX: { type: Number, default: 250 },
initY: { type: Number, default: 150 },
size: { type: Number, default: 48 } // 悬浮球直径,用来算边界
});
const emit = defineEmits(['tap', 'dragEnd']);
const areaWidth = ref(0);
const areaHeight = ref(0);
const ballX = ref(props.initX);
const ballY = ref(props.initY);
let startTime = 0;
let startPointer = { x: 0, y: 0 };
let isDrag = false;
onMounted(() => {
const sys = uni.getSystemInfoSync();
areaWidth.value = sys.windowWidth;
areaHeight.value = sys.windowHeight;
});
function onTouchStart(e) {
startTime = Date.now();
// 记录手指按下的大致位置
const t = e.touches[0] || e.changedTouches[0];
startPointer = { x: t.pageX, y: t.pageY };
isDrag = false;
}
function onChange(e) {
// movable-view 真正位移时触发
if (e.detail.source === 'touch') {
isDrag = true;
ballX.value = e.detail.x;
ballY.value = e.detail.y;
}
}
function onTouchEnd(e) {
const dt = Date.now() - startTime;
const t = e.changedTouches && e.changedTouches[0] ? e.changedTouches[0] : null;
// 说明是拖动结束,而不是点击
if (isDrag) {
emit('dragEnd', {
x: ballX.value,
y: ballY.value
});
// 顺手存一下位置
uni.setStorageSync('floatBallPos', { x: ballX.value, y: ballY.value });
isDrag = false;
return;
}
// 否则就是一次普通点击
emit('tap');
}
</script>
这段代码写出来以后,H5和小程序的手感基本一致了。但如果你在 App 端跑,会发现还有两个小毛病:触摸按键区域太小,取消高亮效果麻烦;flutter 没这个烦恼,因为我们是 uni-app。
五、对click的挣扎:还是自己选中状态更稳
app-vue 环境下 touchstart 后再触发 click 会有一定延迟,而且长按菜单很容易误触。所以我的建议:在业务按钮里加入一个 active 状态,颜色变化来反馈,不要把“长按弹窗”绑定到原生 click 上。
那个项目里的最终用法是用一个按钮把 FloatBall 包起来,内部用 @longpress 去触发菜单。但 longpress 只在部分端支持。统一处理要靠 setTimeout 与 touchstart 配合。这里我不展开了,只说结论:核心还是判断位移量,以位移量取代系统按下的时长判断,才是跨端最准确的手势方案。
六、关于“边界吸附”,一个简单的懒方法
需求里要求“松手后自动吸到屏幕边上”。最顺滑的办法是使用 CSS transition,让 left/right 突变也能动起来。但因为我们是 movable-view,没有直接提供“贴边动画”的属性,因此我选择了手动计算吸附位置后重新设置 ballX。
function onDragEnd(e) {
const { x, y } = e.detail || e;
const sys = uni.getSystemInfoSync();
const { windowWidth, windowHeight } = sys;
const ballSize = props.size;
// 判断靠左还是靠右
let targetX;
if (x + ballSize / 2 < windowWidth / 2) {
targetX = 4; // 左边留 4px
} else {
targetX = windowWidth - ballSize - 4;
}
// 竖直方向尽量保留原 y,但也要保证不超出屏幕
const maxY = windowHeight - ballSize - 4;
const targetY = Math.min(Math.max(0, y), maxY);
// 触发小球动画移动
ballX.value = targetX;
ballY.value = targetY;
}
但如果后续又希望它吸附时有点弹性,不仅是要改 x/y,更要在 movable-view 外嵌套一个带 transition 的容器。由于不同端对 transition 表现不一样,这个坑很深。最后我把“贴边”做成了可选项,默认关闭,因为产品经理也说不上来要不要动画。
七、把这些踩坑总结成“五件事”
这篇文章写了挺多代码,是时候给你捋一下核心心法了。
- 不要轻易全自研触摸拖拽,官方 movable-view 至少保留了三端一致的拖拽体验,自己 touchmove 会导致 page 跟着走。
- 点击和拖拽冲突,唯一的判别标准是移动距离,不是时间。
- 边界坐标要动态取,不要拿硬编码的 375×812,iPhone 用户用起来就露馅。
- 小程序里事件冒泡要单独对待,普通 view 请谨慎用
@touchmove.stop.prevent,建议全部使用movable-area内部处理方法。 - 悬浮球状态要持久化,至少存 storage。因为重新进入页面时间不长,直接读 storage 能避免每次都在左上角闪现。
八、最后的小思考
我还想要“长按变成麦克风”,但长按在 H5 上,往往需要自己模拟左键不放。而且移动端还没有系统级的长按事件兼容层。要想真正业务可用,得封装一个 useTapDrag 组合式函数,把那套 touchstart/touchmove/touchend 距离逻辑变成一个独立模块。
不过那又是另一个故事了。如果你也在用 uniapp 做即时通讯、客服、或者工具型产品,希望这个组件能给你省掉一点头发。有问题欢迎在评论区交流,毕竟跨端 bug 这种东西,一个人看文档是需要巨大勇气的。

