1. 从拖不动到拖得顺:先搞清Draggable的工作原理
做Flutter开发的朋友应该都有过这种经历:需求方甩来一个"把卡片从左边拖到右边"的交互稿,你觉得挺简单,结果一上手就发现——拖动是动了,但东西"放不下"。
这个问题的根源,是对Flutter拖拽体系的理解还停留在"用个组件就能拖"的层面。实际上,Flutter里的拖拽交互从来不是单打独斗,它是一套双组件配合的机制:负责被拖动的Draggable,以及负责接收落点的DragTarget。两个组件通过同一个泛型数据类型串联起来,才能完成"从一个区域拖到另一个区域"的完整闭合。
你可能会问,为什么Flutter不像Web端那样,用一套统一的拖拽事件就完事了?因为移动端的拖拽有太多特殊情况:拖拽过程中父组件要不要跟着滚动?手指按压多久才算拖拽开始?拖到目标区域上要不要给用户视觉反馈?这些如果用底层手势去裸写,每个项目都要重造一遍轮子。Flutter把拖拽拆成触发方和接收方,各管一段,中间用data做数据传递的桥梁,这种设计在实战里其实相当顺手。
这篇文章我从一个完整的实战案例出发,把"数据从一个区域拖动到另一个区域"这件事拆开揉碎,覆盖基础用法、数据传递、命中判定、多目标落点、拖拽反馈、手势冲突这些实战里一定会碰到的点。无论你是刚接触Flutter没多久的新手,还是写了一阵子业务但对拖拽细节一知半解的朋友,这篇文章应该都能给你一些新的思路。
1.1 被拖动的Draggable和被拖入的DragTarget
先花点时间把两个核心组件讲透。
Draggable负责提供"可被拖动的物体"。它包住一个child,你就可以拖着这个child走。但注意,Draggable只是让物体"可以动",它自己并不知道该往哪放。而DragTarget负责"接收"拖过来的物体,它圈定一个区域,当Draggable被拖到这个区域上方并松开手指时,DragTarget的回调就会触发。
这里有个关键点:两个组件必须声明相同的泛型类型。Flutter源码里DragTarget<T>和Draggable<T>的匹配逻辑很简单,只有当泛型类型能成功canAccept的时候,接收方才会把这次拖动视为有效。我在很多项目里见过的问题是,创建Draggable<String>却用DragTarget<Object>去接,结果是控件都在,但数据就是扔不过去,查了半天才发现是泛型不一致。
这两个组件之间传递的data,可以是任何类型:字符串、自定义Model、集合对象,甚至是一个包含回调函数的对象。你把它想成一个快递包裹——Draggable负责打包和寄出,DragTarget负责收货和拆包,而泛型就是快递单上写的品类名,双方对得上,这单才能派送成功。
1.2 data才是跨区域搬数据的核心
我遇到过不少刚上手的朋友,他们会在Draggable的 child 里塞一个状态,然后在DragTarget里试图去读这个child的状态——这条路基本走不通。正确的做法是:onAccept回调的入参,就是你从Draggable的data参数里传出来的东西。
具体来说,你在创建Draggable的时候给它一个data,例如:
Draggable<MyItem>( data: MyItem(id: 'item_1', title: '任务一'), child: MyCard(item: MyItem(id: 'item_1', title: '任务一')), )当用户把这张卡片拖到某个DragTarget<MyItem>上并松手时,DragTarget的回调onAccept(MyItem data)里拿到的data,就是Draggable传给它的那个MyItem实例。数据到这一步才真正从"被拖动的卡片"转移到了"接收区域"。
这个设计的好处是,你的业务逻辑和数据流动是解耦的。卡片长什么样是UI层的事,而到底谁被拖过来了,是数据层的事。后面我们会基于这套机制做很多扩展,比如分类移动、跨容器排序、状态回传,全靠这个data开路。
2. 一套典型场景:把任务卡片从待办区拖进回收区
光讲概念太虚,我拿一个实际项目里的例子来演示:做一个任务管理面板,左边是待办任务列表,右边是回收区。用户把任务卡片从左边拖到右边,松开后卡片从待办列表消失,进入回收记录。这个例子足够简单,但覆盖了拖拽组件最核心的完整闭环。
2.1 定义可拖动的数据模型
不管后面用几个区域,数据模型先定好。我习惯用一个不可变的Model来承载拖拽数据,这样在Draggable和DragTarget之间传递的始终是同一个数据源,不会因为对象被修改产生莫名其妙的bug。
class TaskItem { final String id; final String title; final bool isDone; const TaskItem({ required this.id, required this.title, this.isDone = false, }); TaskItem copyWith({ String? id, String? title, bool? isDone, }) { return TaskItem( id: id ?? this.id, title: title ?? this.title, isDone: isDone ?? this.isDone, ); } }如果你有经验会知道,拖拽过程中UI状态频繁变化,copyWith是少不了的。后面要做到"拖回待办区"这类反向操作时,这个模型还能直接复用。
2.2 可拖动的列表项与接收区域完整示例
核心代码结构是这样:
class TaskTransferPage extends StatefulWidget { @override State<TaskTransferPage> createState() => _TaskTransferPageState(); } class _TaskTransferPageState extends State<TaskTransferPage> { final List<TaskItem> _todoTasks = [ TaskItem(id: '1', title: '整理周报'), TaskItem(id: '2', title: '修复登录bug'), TaskItem(id: '3', title: '设计评审'), ]; final List<TaskItem> _trashTasks = []; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('任务拖拽演示')), body: Row( children: [ // 左侧待办区 Expanded( child: ListView.builder( itemCount: _todoTasks.length, itemBuilder: (context, index) { final task = _todoTasks[index]; return Draggable<TaskItem>( data: task, feedback: Material( elevation: 6, child: TaskCard(task: task), ), childWhenDragging: Opacity( opacity: 0.3, child: TaskCard(task: task), ), child: TaskCard(task: task), ); }, ), ), // 右侧回收区 Expanded( child: DragTarget<TaskItem>( onWillAcceptWithDetails: (details) { return true; }, onAcceptWithDetails: (details) { setState(() { _todoTasks.removeWhere((t) => t.id == details.data.id); _trashTasks.add(details.data); }); }, builder: (context, candidateData, rejectedData) { final isActive = candidateData.isNotEmpty; return Container( decoration: BoxDecoration( color: isActive ? Colors.red.withOpacity(0.2) : Colors.grey.withOpacity(0.1), borderRadius: BorderRadius.circular(12), ), alignment: Alignment.center, child: Text(isActive ? '松手放入回收站' : '拖到这里回收'), ); }, ), ), ], ), ); } }这套结构就是"从一个区域拖到另一个区域"的最小完整实现。等会我们逐段拆开讲为什么这么写,以及真实项目里会碰到的坑。
2.3 关键回调的触发时机与选择依据
很多人在DragTarget的回调选择上会犹豫,这里直接给你结论,都是我实际验证过的:
| 回调 | 触发时机 | 使用场景 |
|---|---|---|
onWillAccept | 拖拽物进入目标区域时触发一次,返回bool决定是否允许本次拖入 | 做权限校验、列表包含校验 |
onWillAcceptWithDetails | 同上,但会携带DragTargetDetails<T> | 需要在进入时读取拖拽数据做判断 |
onAccept | 松手且onWillAccept返回true时触发 | 执行数据转移、状态变更 |
onAcceptWithDetails | 同上,携带细节数据 | 推荐首选 |
onLeave | 拖拽物离开目标区域时触发 | 重置区域高亮、取消 hover 状态 |
onMove | 拖拽物在目标区域上方移动时触发 | 需要实时反馈的复杂场景 |
简单说,能用WithDetails版本就不要用旧版无参回调。onAccept不带参数,你还得通过别的途径拿到data,绕了一圈没意义。onAcceptWithDetails直接从details.data取出数据,写法直观很多。
有一个细节我经常看到有人弄错:onWillAccept的返回值只在第一次进入区域时被评估,如果在区域内来回移动,它不会反复触发。如果你需要根据拖拽物在区域内移动时的数据变化动态决定是否允许放入,得配合onMove来做。