Drag and Drop in Flutter: Draggable, DragTarget, and LongPressDraggable
Drag-and-drop looks simple until a drag fights a scroll view or the feedback widget vanishes. Here is how Draggable and DragTarget actually work, and the parameters that decide how a drag behaves.
Published on • October 14, 2026
AI Assistant

Flutter ships drag-and-drop as a pair of widgets rather than a gesture callback: a Draggable that carries a typed payload, and a DragTarget that decides whether it wants that payload. Everything else — the ghost image that follows the finger, the highlight while hovering, what happens on a rejected drop — is configuration on those two widgets.
Once you know which parameter controls which part of the interaction, most drag-and-drop bugs turn out to be one of three things: the wrong widget is showing during the drag, the target is not participating in hit testing, or the drag never started because a Scrollable won the gesture arena.
The two-widget contract
Draggable<T> renders child normally. When a drag begins it renders feedback in an overlay that tracks the pointer, and it carries data of type T. When the pointer is released over a target, the target receives that data.
DragTarget<T> is asked twice: first whether it will accept the data (while the finger is hovering), then to actually accept it on drop.
Draggable<int>(
data: task.id,
feedback: Material(
elevation: 8,
child: TaskTile(task: task, width: 260),
),
childWhenDragging: TaskTile(task: task, opacity: 0.3),
child: TaskTile(task: task),
)
DragTarget<int>(
onAcceptWithDetails: (details) => moveTask(details.data, column.id),
builder: (context, candidates, rejected) => DropZone(
highlighted: candidates.isNotEmpty,
child: TaskList(column.id),
),
)
The builder gives you candidates (draggables currently hovering that this target might accept) and rejected (hovering but not acceptable). Use them to drive the visual state — a border, a tint, a scale — because that feedback is what makes a drop target feel discoverable.
The parameters that decide behavior
feedback vs child vs childWhenDragging. feedback is what the finger drags around; it is drawn in an Overlay, so it can float above sibling widgets and outside the parent’s clip. child stays in place, and childWhenDragging replaces it while a drag is under way. Forgetting childWhenDragging is why a tile appears twice during a drag — once in place and once under the finger.
data must be non-null for the target to fire. DragTarget.onAcceptWithDetails and onMove are not called when a draggable’s data is null. A Draggable with no data can still drive UI (drag-to-dismiss, for instance) but nothing will ever be accepted.
axis and affinity constrain who wins the gesture. Set axis: Axis.vertical to lock movement, which also stops horizontal drags from being recognized. affinity controls how the widget competes with other gesture recognizers — set affinity: Axis.vertical on a draggable inside a vertical list so the list scrolls when the user swipes vertically and the drag starts on a mostly-horizontal movement.
maxSimultaneousDrags defaults to unlimited, which means multi-touch users can start several drags at once. Set it to 1 when the interaction assumes a single dragged object.
dragAnchorStrategy and feedbackOffset tune where the feedback sits relative to the pointer. The default childDragAnchorStrategy anchors at the child’s origin, which is what you want for cards; pointerDragAnchorStrategy anchors at the pointer position, which feels better for small icons.
Callbacks: knowing exactly where a drag ended
Draggable exposes the full lifecycle:
| Callback | Fires when |
|---|---|
onDragStarted | the drag begins |
onDragUpdate | the pointer moves (gives you globalPosition) |
onDragEnd | the pointer is released — always, accepted or not |
onDragCompleted | released and accepted by a target |
onDraggableCanceled | released without being accepted |
The ordering matters: onDragEnd runs before either onDragCompleted or onDraggableCanceled. Reset shared state in onDragEnd, and use the accept-specific callbacks for the actual business operation.
On the target side, prefer the details-based callbacks — onWillAcceptWithDetails and onAcceptWithDetails — because the older onWillAccept/onAccept are deprecated since Flutter 3.14 and hide the drop location. DragTargetAcceptDetails.offset tells you exactly where inside the target the item landed, which is how you implement reordering within a list.
onWillAcceptWithDetails is also where validation lives: return false for a payload this zone cannot take, and the target visibly rejects it (your builder gets the draggable in rejected instead of candidates).
DragTarget<Task>(
onWillAcceptWithDetails: (d) =>
d.data.status != column.status && column.isAccepting(d.data),
onAcceptWithDetails: (d) {
final dropOffset = d.offset; // global drop position
repo.moveTo(d.data, column.status);
},
onLeave: (_) => setHighlighted(false), // finger left with this data
onMove: (d) => previewReorder(d.data, d.offset),
builder: (context, candidates, rejected) { /* ... */ },
)
LongPressDraggable: drags that do not steal scrolls
The most common complaint is “my draggable never starts inside a ListView.” Both recognizers want the initial pan, and the scrollable usually wins.
LongPressDraggable resolves this cleanly: the drag starts only after a long press, leaving plain pans to the scroller. It is a Draggable subclass, so every parameter above still applies. For reorderable lists, combine it with ReorderableListView or use onWillAcceptWithDetails against the target’s onMove to preview the insertion index.
If you truly need instant drags inside a scroller, the alternative is affinity plus a restricted axis — but expect to tune it per platform, since iOS and Android resolve the arena differently.
Performance and accessibility notes
feedback is a real widget in the overlay. Keep it cheap: a static snapshot, an Image.memory, or a simplified card — not the full interactive tile with its own buttons and animations. Buttons inside feedback do not receive taps anyway, because ignoringFeedbackPointer defaults to true.
Blur and elevation on feedback help it read as “lifted,” but avoid re-running expensive builds every onDragUpdate; store the pointer position in state and let setState drive a Transform.translate, or use a ValueNotifier to avoid rebuilding the whole screen.
For semantics, feedback defaults to ignoringFeedbackSemantics: true so screen readers do not announce a second copy of the widget mid-drag. Provide your own announcements instead — for example an SemanticsService.announce('Picked up $task', TextDirection.ltr) on onDragStarted and ...dropped in $column on onDragCompleted — and give the DragTarget a semantic label describing what it accepts.
A minimal but complete pattern
class TaskBoard extends StatefulWidget {
const TaskBoard({super.key});
@override
State<TaskBoard> createState() => _TaskBoardState();
}
class _TaskBoardState extends State<TaskBoard> {
final columns = {'todo': <int>[], 'doing': <int>[], 'done': <int>[]};
@override
Widget build(BuildContext context) {
return Row(
children: columns.entries.map((entry) {
return DragTarget<int>(
onWillAcceptWithDetails: (d) => !entry.value.contains(d.data),
onAcceptWithDetails: (d) => setState(() {
columns.values.forEach((ids) => ids.remove(d.data));
entry.value.add(d.data);
}),
builder: (context, candidates, rejected) => Container(
width: 240,
margin: const EdgeInsets.all(8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: candidates.isNotEmpty
? Colors.blue
: Colors.grey.shade300,
width: candidates.isNotEmpty ? 2 : 1,
),
),
child: Column(
children: [
for (final id in entry.value)
Draggable<int>(
data: id,
feedback: Material(
elevation: 6,
child: TaskTile(id: id, width: 220),
),
childWhenDragging: Opacity(
opacity: 0.3,
child: TaskTile(id: id),
),
child: TaskTile(id: id),
),
],
),
),
);
}).toList(),
);
}
}
The pattern generalizes: a Draggable that carries typed data, a DragTarget whose builder renders highlight state from candidates, and cleanup in onAcceptWithDetails that removes the item from its previous home. Everything else is tuning.
Checklist
- Show
childWhenDraggingso the source does not appear twice. - Give
Draggableadatavalue or targets will never fire. - Use
onWillAcceptWithDetails/onAcceptWithDetails, not the deprecated callbacks. - Handle
onDragEnd(always) separately fromonDragCompleted/onDraggableCanceled. - Reach for
LongPressDraggablewhen a drag fights a scroll view. - Keep
feedbackcheap and announce drag events for screen readers.
Sources: Draggable API, DragTarget API, Flutter documentation.