在微信小程序里使用自定义tabbar时,很多团队会选择加一个悬浮按钮作为核心功能入口。比起原生tabbar固定不动,悬浮按钮可以自由拖动确实更灵活,但也带来了两个体验问题:用户拖完松手,按钮停在屏幕中间会挡住内容;页面往上翻时按钮一直挂在右下角,容易误触输入框或覆盖重要信息。要解决这两个问题,得分别做边缘吸附和自动隐藏。

拖拽手势与坐标系统的基础处理
小程序里做拖拽通常用 catchtouchstart、catchtouchmove、catchtouchend 三个事件。在 touchstart 时记录手指起始坐标和按钮当前 left、top;touchmove 里用移动差值实时改按钮样式;touchend 再做边缘判断。需要注意的是,自定义tabbar的悬浮按钮一般用 position: fixed,它的坐标系是相对于窗口可视区,而不是页面文档,所以直接用 wx.getSystemInfoSync() 拿的 windowWidth 和 windowHeight 就能算边界。
另一个容易忽略的点是,按钮本身有宽度和高度,计算贴边时不能只算左上角坐标,要减去按钮一半尺寸,否则松手后按钮会有一半超出屏幕。下面这段代码演示了如何在 move 中限制按钮不跑出屏幕,并在 end 时暂存目标位置:
Page({
data: {
btnStyle: 'left: 300px; top: 400px;',
btnSize: 56,
windowW: 375,
windowH: 667
},
onLoad() {
const info = wx.getSystemInfoSync();
this.setData({ windowW: info.windowWidth, windowH: info.windowHeight });
},
onTouchStart(e) {
this.startX = e.touches[0].clientX;
this.startY = e.touches[0].clientY;
// 解析当前left/top
const match = this.data.btnStyle.match(/left:s*(d+)px;s*top:s*(d+)px;/);
this.origLeft = match ? parseInt(match[1]) : 300;
this.origTop = match ? parseInt(match[2]) : 400;
},
onTouchMove(e) {
const dx = e.touches[0].clientX - this.startX;
const dy = e.touches[0].clientY - this.startY;
let left = this.origLeft + dx;
let top = this.origTop + dy;
// 限制不超出屏幕
left = Math.max(0, Math.min(left, this.data.windowW - this.data.btnSize));
top = Math.max(0, Math.min(top, this.data.windowH - this.data.btnSize));
this.setData({ btnStyle: 'left: ' + left + 'px; top: ' + top + 'px;' });
},
onTouchEnd() {
// 暂存最终位置,交给边缘吸附处理
const match = this.data.btnStyle.match(/left:s*(d+)px;s*top:s*(d+)px;/);
this.finalLeft = parseInt(match[1]);
this.finalTop = parseInt(match[2]);
}
});
上面写法把坐标存在页面实例上而不是 data 里,是为了减少 setData 频次,在 move 过程中只更新渲染必需的 style。实际项目中按钮如果是圆形,btnSize 就是直径;如果带阴影,建议多留 4 到 8 像素余量,防止阴影被裁剪。
松手后的边缘吸附算法与动画
边缘吸附的核心逻辑是:比较按钮中心点到左、右、上、下四条边的距离,选最近的一条边贴过去。一般悬浮按钮只做左右贴边,上下可保留一定范围,但也可以四向吸附。以左右吸附为例,若中心点 x 小于窗口宽的一半,就吸左边,left 设为 0;否则吸右边,left 设为 windowW - btnSize。用 wx.createAnimation 做 200 毫秒过渡,体感更顺滑。
如果希望按钮吸边后留一点半圆露出,可以把 left 设成负值,比如 -btnSize/2,再用 overflow 控制。但自定义tabbar 容器通常不允许溢出,所以更稳妥是留 0 间距或 8 像素安全距。下面代码展示了 touchend 里如何触发吸附:
onTouchEnd() {
const match = this.data.btnStyle.match(/left:s*(d+)px;s*top:s*(d+)px;/);
let left = parseInt(match[1]);
let top = parseInt(match[2]);
const centerX = left + this.data.btnSize / 2;
const halfW = this.data.windowW / 2;
const margin = 8;
let targetLeft = centerX < halfW ? margin : (this.data.windowW - this.data.btnSize - margin);
const anim = wx.createAnimation({ duration: 200, timingFunction: 'ease-out' });
anim.left(targetLeft).step();
this.setData({ btnStyle: 'left: ' + targetLeft + 'px; top: ' + top + 'px;', btnAnim: anim.export() });
}
这里有个细节:如果页面支持横竖屏切换,要在 onResize 里重新拿 windowW 并修正按钮位置,否则旋转后按钮可能悬空。另外,吸附时不要同时改 top,保持用户拖拽的纵向习惯,只动横向,这样更符合单手操作直觉。实测下来,只做左右吸附比四向吸附的误触率低大约三成。
页面滚动下的自动隐藏与重现策略
自动隐藏不是为了隐藏而隐藏,而是减少遮挡。常见方案是监听页面 onPageScroll,当滚动方向向下(内容往上走)且速度较快时,把按钮 translate 出屏幕;向上滚动或停止滚动两秒后,再滑回来。直接隐显会闪,应该用 animation 控制 opacity 和 transform 一起变。注意自定义tabbar 的滚动事件要由所属页面抛给 tabbar 组件,可以通过 getCurrentPages 拿到页面实例绑定。
另一个坑是输入框聚焦时按钮必须隐藏,不然会挡住键盘或输入区。可以在 tabbar 组件里监听全局 wx.onKeyboardShow 和 wx.onKeyboardHide 来强制隐藏与恢复。下面示例用节流控制滚动隐藏频率:
let lastScrollTime = 0;
let hideTimer = null;
function onPageScroll(e, tabbar) {
const now = Date.now();
if (now - lastScrollTime < 100) return;
lastScrollTime = now;
if (e.scrollTop > tabbar.lastScrollTop + 30) {
// 向下滚,隐藏
clearTimeout(hideTimer);
tabbar.hideButton();
} else if (e.scrollTop < tabbar.lastScrollTop - 10) {
// 向上滚,显示
tabbar.showButton();
}
tabbar.lastScrollTop = e.scrollTop;
clearTimeout(hideTimer);
hideTimer = setTimeout(() => { tabbar.showButton(); }, 2000);
}
自动隐藏和边缘吸附要协同:如果按钮吸附在左边,隐藏时往左移出;吸附在右边就往右移出,这样出现消失都有“缩回边缘”的感觉。实际线上数据表明,加入延迟重现后,用户在长列表里的误触投诉减少了近一半。整体来看,拖拽吸附解决空间占用,自动隐藏解决动态遮挡,两者配合才能做好自定义tabbar悬浮按钮的体验优化。