uni-app 自定义悬浮球拖拽组件:跨端兼容与边界处理实录

2026-08-07 0 1,029

做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-areamovable-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-viewcover-image实现悬浮球,但cover-view的限制很多(不支持的css属性、不可嵌套某些标签)而且不支持复杂的touchmove?实际上cover-view支持touch事件,但你没办法在cover-view上使用动态的left/top进行拖拽?可以,还是position:fixed。
  • 更通用的方案:避免在需要覆盖原生组件的场景下使用悬浮球,或者用subNVue(App端)代替。小程序端没有完美方案。

由于我的项目里悬浮球主要出现在普通列表页面,没有覆盖原生组件,所以我没做特殊处理。但如果你的业务正好需要,那只能单独写一个原生cover-view版本,我建议还是让产品取消这个需求吧。

十四、一番折腾后的总结

可拖拽悬浮球的难点不在于拖拽逻辑,而在于各端的一致性。如果你只用H5测试,会觉得很顺利。但发布到小程序和App后,各种意外接踵而至。我的最终建议是:

  1. 使用position:fixed定位,不要用absolute。
  2. 使用@touchmove.stop.prevent阻止滚动穿透。
  3. 缓存窗口尺寸,不要在touchmove里调用getSystemInfo。
  4. 点击和拖拽通过位移阈值区分。
  5. 吸边动画用setInterval,但要处理销毁和打断。
  6. 在nvue里就放弃用这个组件,换成movable-view重写。

我把这套逻辑封装成了uni_modules形式,在项目里npm安装就能用。写完之后再回头看,其实能踩的坑也就是这几个。分享出来,主要还是希望你们不用再为“为什么小程序上拖不动”而翻几十篇博客。

uni-app 自定义悬浮球拖拽组件:跨端兼容与边界处理实录
收藏 (0) 打赏

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

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

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

淘吗网 uniapp uni-app 自定义悬浮球拖拽组件:跨端兼容与边界处理实录 https://www.taomawang.com/web/uniapp/2503.html

常见问题

相关文章

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

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