最近在做一款直播伴侣类的工具时,遇到了一个很典型的需求:页面上需要一个悬浮入口按钮,用户既希望它能随手拖到任意位置,又希望它别一直霸占屏幕中央影响观看内容。产品给的原话是“做个能吸边、还能藏一半的悬浮球”,正好就是标题里说的这个——Flutter可吸边且隐藏一半的拖拽悬浮按钮。不管你是刚把Flutter环境装好、正在找练手组件的新手,还是已经在做客服、直播、课程助手这类App的开发者,这个组件都值得自己手写一遍。市面上的第三方库不少,但版本跟进慢、参数锁得死,真正要适配自己业务时还得改源码,不如从零实现一个,还能彻底搞懂里面的手势、坐标、动画这些关键机制。
1. 先把需求掰开揉碎:这个按钮到底要干哪些事
1.1 一个悬浮按钮背后至少藏着四个诉求
很多人拿到这个需求会以为就是一个Draggable再加个Alignment就能搞定,真动手以后才发现,表面一句话背后其实有四个独立的功能点。
第一是“可拖拽”。这个好理解,手指按住、拖动、松手,按钮得跟着走。但这里的“走”不是漫无目的地乱走,而是要限制在屏幕可视范围内,不能拖到状态栏上面去,也不能整个掉出屏幕外面。第二是“吸边”,也就是松手后按钮不是停在原地,而是自动滑向离它最近的那条屏幕边缘,左边近就吸左边,右边近就吸右边。第三是“隐藏一半”,吸边完成后按钮要有大概一半(或者按设计指定的比例)藏在屏幕外面,只露出一部分来提示用户“我在这儿”。第四是“点击展开”,既然藏起来了,那用户想完整操作它的时候就得有个交互:点击露出的那部分,按钮滑出来完整显示。
这四个诉求是环环相扣的。拖拽是基础,吸边是拖拽结束后的收尾动作,隐藏一半是吸边之后的形态变化,点击展开则是从隐藏态恢复成完整态的动画过渡。任何一个环节没处理好,都会让整个按钮显得“不对劲”。
1.2 “隐藏一半”这个设计为什么合理
在实际产品里,悬浮按钮如果一直是完整显示的状态,其实挺烦人的。屏幕中间的弹幕、视频画面、直播礼物动画都可能被它挡住,用户想看清内容就得手动把它挪走,挪完它还在那儿大喇喇地占着一块地方。
隐藏一半这个设计巧妙在它同时满足了两个诉求:一是保留了便捷入口,用户不需要满屏找按钮,扫一眼屏幕边缘就能看到;二是最大限度减少了视觉遮挡,半个按钮的宽度通常只有20到30个像素,几乎不挡内容。而且这个“隐藏一半”还自带一种视觉暗示——半截按钮露在屏幕边缘,用户一看就知道这东西是可交互的、能拖出来的,不需要任何新手引导。
我这里说的“一半”其实是个形象说法,实际实现中露出的宽度不一定是严格的50%,通常会在20到24像素左右,既能看清图标轮廓,又不会太占地方。具体数值后面可以做成参数,不同业务自己调。
1.3 我最终圈定的可运行范围
在动手之前,我先明确了几个基础参数,避免写到一半陷入选择困难:
- 按钮默认直径:56(Material规范里FAB的尺寸)
- 吸边后露出宽度:20
- 吸边状态距离屏幕边缘的间距:8
- 拖拽过程中上下的安全范围:顶部避开状态栏,底部避开系统导航条,用MediaQuery拿到具体值
实现范围上,我先做的是单页面内的悬浮球,也就是把组件挂在当前页面的Stack里。如果你需要的是全App全局悬浮(切页面也不消失),原理是一样的,只是要把组件放到Overlay里,这个我后面单独讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么选GestureDetector而不是Draggable
2.1 Draggable的适用场景
Flutter官方提供的Draggable组件,设计初衷是“把某个东西拖到另一个东西上面去”,典型的场景是拖拽排序、拖拽到回收站、拖拽到某个容器里。它天生绑定DragTarget(拖拽目标),拖拽过程中的回调、拖拽结束后的落点判断,全都是围绕“目标区域”来设计的。
但悬浮按钮的需求恰恰不是这样。它不需要知道自己落在了哪个目标上,它只需要知道自己最后停在了屏幕的哪个坐标,然后根据坐标决定往左吸还是往右吸。Draggable在这里反而成了累赘,因为它的feedback、childWhenDragging这些机制,以及它内部对手势竞技场的处理,都会让“自由移动+吸边”这种逻辑变得绕。
2.2 GestureDetector + Positioned的组合才是正道
我最终用的是GestureDetector的onPanUpdate系列回调,配合Stack + Positioned来定位。核心逻辑就一句话:手指移动多少,按钮的left和top就跟着加多少,松手之后做吸边计算。
这套组合的逻辑非常直观,没有任何中间层。onPanUpdate回调里会给一个details.delta,这个值就是本次回调相对于上次回调的位移增量,直接用这个增量去更新坐标就行。之所以不用onHorizontalDragUpdate和onVerticalDragUpdate分开监听,是因为悬浮按钮的拖拽是自由方向的,onPanUpdate能同时拿到水平和垂直两个方向的增量。
这里还有一个细节值得注意:GestureDetector只负责手势识别,不负责布局。把GestureDetector放进Positioned里,Positioned负责按钮在Stack中的位置,GestrueDetector负责把手势回调转换成位置变化,两者各司其职。
2.3 方案对比:为什么不用第三方库
我也看过几个Flutter悬浮按钮的第三方库,比如flutter_draggable_fab这类,功能确实开箱即用,但问题也不少。
| 对比维度 | 自己实现 | 第三方库 |
|---|---|---|
| 吸边动画可控性 | 完全可控 | 受库参数限制 |
| 隐藏一半的比例 | 任意调整 | 很多库不支持 |
| 点击展开的交互 | 自由定义 | 多数只做单次点击回调 |
| 版本升级适配 | 无需等待 | 库作者不维护就GG |
| 代码侵入性 | 组件化封装后很低 | 需要迁就库的API |
最现实的痛点就是第三方库的更新速度。Flutter本身一年要发好几个大版本,有些库半年不更新,你升级一下Flutter版本就编译不过,最后还是要自己改源码。与其改别人的还看不明白,不如自己写一个,总共就是几个回调加几行坐标计算,算上动画半小时搞定。
3. 核心逻辑实现:从零手写一个能用的悬浮球
3.1 先搭一个最简可运行的骨架
组件类我命名为SnapDockButton,它的最外层结构是这样的:
dart复制class SnapDockButton extends StatefulWidget {
final double size; // 按钮直径
final double hiddenWidth; // 吸边后露出的宽度
final Widget child;
