我用uniapp和vue3写了个悬浮球组件,真机跑完我懂了这5件事

2026-09-06 0 395

接了个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-areamovable-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 表现不一样,这个坑很深。最后我把“贴边”做成了可选项,默认关闭,因为产品经理也说不上来要不要动画。

七、把这些踩坑总结成“五件事”

这篇文章写了挺多代码,是时候给你捋一下核心心法了。

  1. 不要轻易全自研触摸拖拽,官方 movable-view 至少保留了三端一致的拖拽体验,自己 touchmove 会导致 page 跟着走。
  2. 点击和拖拽冲突,唯一的判别标准是移动距离,不是时间。
  3. 边界坐标要动态取,不要拿硬编码的 375×812,iPhone 用户用起来就露馅。
  4. 小程序里事件冒泡要单独对待,普通 view 请谨慎用 @touchmove.stop.prevent,建议全部使用 movable-area 内部处理方法。
  5. 悬浮球状态要持久化,至少存 storage。因为重新进入页面时间不长,直接读 storage 能避免每次都在左上角闪现。

八、最后的小思考

我还想要“长按变成麦克风”,但长按在 H5 上,往往需要自己模拟左键不放。而且移动端还没有系统级的长按事件兼容层。要想真正业务可用,得封装一个 useTapDrag 组合式函数,把那套 touchstart/touchmove/touchend 距离逻辑变成一个独立模块。

不过那又是另一个故事了。如果你也在用 uniapp 做即时通讯、客服、或者工具型产品,希望这个组件能给你省掉一点头发。有问题欢迎在评论区交流,毕竟跨端 bug 这种东西,一个人看文档是需要巨大勇气的。

我用uniapp和vue3写了个悬浮球组件,真机跑完我懂了这5件事
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 uniapp 我用uniapp和vue3写了个悬浮球组件,真机跑完我懂了这5件事 https://www.taomawang.com/web/uniapp/2715.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务