Flutter Draggable and DragTarget: Build Drag-and-Drop UI

Coding Liquids tutorial cover featuring Sagnik Bhattacharya for drag-and-drop UI.
Coding Liquids tutorial cover featuring Sagnik Bhattacharya for drag-and-drop UI.

You will build a small grocery board where fresh items can be dragged into a basket while unsuitable or duplicate items are refused. The example makes the acceptance decision visible, so users can tell whether releasing the pointer will change anything.

Follow me on Instagram@sagnikteaches

This tutorial connects a typed Draggable to a DragTarget, supplies dedicated feedback and placeholder widgets, and uses the current details-based target callbacks. It also covers long-press activation, drag lifecycle events, accessibility, and a widget test for accepted and rejected drops.

Connect on LinkedInSagnik Bhattacharya

The code uses only Flutter's widgets library, so no package installation or platform configuration is required. Remember that the draggable's generic type is the contract between source and destination: keep domain data in that contract rather than passing a display label or an untyped map.

Subscribe on YouTube@codingliquids
The Complete Flutter Guide course thumbnail

The Complete Flutter Guide: Build Android, iOS and Web apps

Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.

Enrol now

Make the payload describe the item, not its appearance

A drag operation becomes easier to maintain when it transports a domain object. The target can then inspect meaningful fields without parsing text from a widget or looking up state through a global identifier. Here, every source carries a GroceryItem whose group determines whether the basket may accept it.

enum FoodGroup {
  fresh,
  cupboard,
}

class GroceryItem {
  const GroceryItem({
    required this.name,
    required this.icon,
    required this.group,
  });

  final String name;
  final IconData icon;
  final FoodGroup group;
}

const groceries = <GroceryItem>[
  GroceryItem(
    name: 'Apple',
    icon: Icons.apple,
    group: FoodGroup.fresh,
  ),
  GroceryItem(
    name: 'Carrot',
    icon: Icons.eco,
    group: FoodGroup.fresh,
  ),
  GroceryItem(
    name: 'Beans',
    icon: Icons.inventory_2,
    group: FoodGroup.cupboard,
  ),
];

Using Draggable<GroceryItem> and DragTarget<GroceryItem> gives the callbacks a real GroceryItem. A misspelt map key or an accidental integer payload becomes a compile-time problem instead of a failed drop at runtime. Immutable objects also make it clear that dragging communicates a value; the target remains responsible for changing application state.

Build a fresh-food drop zone from end to end

The following application is complete and paste-able as lib/main.dart. It keeps the source items available, prevents duplicates in the basket, and accepts only items marked as fresh. The target's builder distinguishes a valid candidate from rejected data while the pointer is hovering above it.

import 'package:flutter/material.dart';

void main() {
  runApp(const DragDropApp());
}

enum FoodGroup {
  fresh,
  cupboard,
}

class GroceryItem {
  const GroceryItem({
    required this.name,
    required this.icon,
    required this.group,
  });

  final String name;
  final IconData icon;
  final FoodGroup group;
}

const groceries = <GroceryItem>[
  GroceryItem(
    name: 'Apple',
    icon: Icons.apple,
    group: FoodGroup.fresh,
  ),
  GroceryItem(
    name: 'Carrot',
    icon: Icons.eco,
    group: FoodGroup.fresh,
  ),
  GroceryItem(
    name: 'Beans',
    icon: Icons.inventory_2,
    group: FoodGroup.cupboard,
  ),
];

class DragDropApp extends StatelessWidget {
  const DragDropApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: const ShoppingBoard(),
      theme: ThemeData(
        colorSchemeSeed: Colors.teal,
        useMaterial3: true,
      ),
    );
  }
}

class ShoppingBoard extends StatefulWidget {
  const ShoppingBoard({super.key});

  @override
  State<ShoppingBoard> createState() => _ShoppingBoardState();
}

class _ShoppingBoardState extends State<ShoppingBoard> {
  final List<GroceryItem> _basket = <GroceryItem>[];

  Widget _buildSource(GroceryItem item) {
    return Draggable<GroceryItem>(
      data: item,
      feedback: Material(
        color: Colors.transparent,
        child: SizedBox(
          width: 180,
          child: ItemTile(item: item, elevated: true),
        ),
      ),
      childWhenDragging: SizedBox(
        width: 180,
        height: 64,
        child: DecoratedBox(
          decoration: BoxDecoration(
            border: Border.all(color: Colors.grey),
            borderRadius: BorderRadius.circular(12),
          ),
          child: Center(
            child: Text('${item.name} is moving'),
          ),
        ),
      ),
      child: SizedBox(
        width: 180,
        child: ItemTile(item: item),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Fresh food basket')),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(24),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            const Text(
              'Drag a fresh item into the basket',
              style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
            ),
            const SizedBox(height: 16),
            Wrap(
              spacing: 12,
              runSpacing: 12,
              children: groceries.map(_buildSource).toList(),
            ),
            const SizedBox(height: 32),
            DragTarget<GroceryItem>(
              onWillAcceptWithDetails: (details) {
                final item = details.data;
                return item.group == FoodGroup.fresh &&
                    !_basket.contains(item);
              },
              onAcceptWithDetails: (details) {
                final item = details.data;
                setState(() {
                  _basket.add(item);
                });
                ScaffoldMessenger.of(context).showSnackBar(
                  SnackBar(content: Text('${item.name} added')),
                );
              },
              builder: (context, candidateData, rejectedData) {
                final isAccepting = candidateData.isNotEmpty;
                final isRejecting = rejectedData.isNotEmpty;
                final candidate =
                    isAccepting ? candidateData.first : null;

                final Color background;
                final Color border;
                if (isAccepting) {
                  background = Colors.green.shade50;
                  border = Colors.green;
                } else if (isRejecting) {
                  background = Colors.orange.shade50;
                  border = Colors.orange;
                } else {
                  background = Colors.grey.shade100;
                  border = Colors.grey;
                }

                final String instruction;
                if (isAccepting) {
                  instruction = 'Release to add ${candidate!.name}';
                } else if (isRejecting) {
                  instruction =
                      'Only new fresh items can be added';
                } else {
                  instruction = 'Drop fresh food here';
                }

                return AnimatedContainer(
                  duration: const Duration(milliseconds: 180),
                  constraints: const BoxConstraints(minHeight: 190),
                  padding: const EdgeInsets.all(20),
                  decoration: BoxDecoration(
                    color: background,
                    border: Border.all(color: border, width: 2),
                    borderRadius: BorderRadius.circular(16),
                  ),
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      Icon(
                        isAccepting ? Icons.add_circle : Icons.shopping_basket,
                        size: 42,
                        color: border,
                      ),
                      const SizedBox(height: 8),
                      Text(
                        instruction,
                        textAlign: TextAlign.center,
                        style: const TextStyle(fontWeight: FontWeight.bold),
                      ),
                      const SizedBox(height: 12),
                      for (final item in _basket)
                        Text('Basket: ${item.name}'),
                    ],
                  ),
                );
              },
            ),
          ],
        ),
      ),
    );
  }
}

class ItemTile extends StatelessWidget {
  const ItemTile({
    required this.item,
    this.elevated = false,
    super.key,
  });

  final GroceryItem item;
  final bool elevated;

  @override
  Widget build(BuildContext context) {
    return Card(
      elevation: elevated ? 8 : 1,
      child: ListTile(
        leading: Icon(item.icon),
        title: Text(item.name),
        subtitle: Text(
          item.group == FoodGroup.fresh ? 'Fresh' : 'Cupboard',
        ),
      ),
    );
  }
}

setState runs only in onAcceptWithDetails, after the target has approved the payload. Keeping mutation out of the eligibility callback matters because Flutter may invoke that callback repeatedly as the pointer enters or moves within a target.

Separate the resting child from the moving feedback

A Draggable can display three related widgets. child is the normal source, feedback is rendered in an overlay during the drag, and childWhenDragging temporarily occupies the source position. They do not have to share the same layout, although retaining similar dimensions prevents surrounding content from jumping.

The example wraps its feedback in Material because an overlay does not automatically inherit the nearest card's Material surface. Without that wrapper, text decoration and other Material-dependent visuals can look different while moving. A fixed feedback width also prevents an unconstrained ListTile from throwing a layout error in the overlay.

By default, feedback does not intercept pointer hit testing, allowing the target underneath it to receive the drag. If you change ignoringFeedbackPointer, verify the result carefully. A large opaque feedback widget may otherwise make a correctly typed target appear unresponsive.

The Complete Flutter Guide course thumbnail

The Complete Flutter Guide: Build Android, iOS and Web apps

Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.

Enrol now

Use the details callbacks to decide and complete a drop

onWillAcceptWithDetails answers a narrow question: would this target accept the candidate currently over it? Its DragTargetDetails supplies the typed data and the global pointer offset. Return true only when the drop is valid; returning false makes the payload available through rejectedData in the builder.

onAcceptWithDetails is called after the user releases an approved draggable. That is the appropriate place to insert, reorder, transfer, or persist the item. If position matters—for example, when dropping into a canvas—convert details.offset from global coordinates with a RenderBox belonging to the target.

final RenderBox box =
    targetKey.currentContext!.findRenderObject()! as RenderBox;
final Offset localDropPosition =
    box.globalToLocal(details.offset);

Do not call that conversion from a context belonging to the page or draggable; it would establish the wrong coordinate system. Also avoid retaining a BuildContext merely for later drag calculations. A GlobalKey attached to the positioned surface gives the conversion a precise reference.

Choose long-press activation inside scrollable content

Immediate dragging can compete with scrolling because both gestures begin with pointer movement. LongPressDraggable delays entry into the drag until the press has been held, which is often a better fit for reorderable cards, boards, and mobile lists. It still carries the same typed data and works with the existing DragTarget<GroceryItem>.

LongPressDraggable<GroceryItem>(
  data: item,
  delay: const Duration(milliseconds: 350),
  maxSimultaneousDrags: 1,
  hapticFeedbackOnStart: true,
  feedback: Material(
    color: Colors.transparent,
    child: SizedBox(
      width: 180,
      child: ItemTile(item: item, elevated: true),
    ),
  ),
  childWhenDragging: Opacity(
    opacity: 0.35,
    child: SizedBox(
      width: 180,
      child: ItemTile(item: item),
    ),
  ),
  child: SizedBox(
    width: 180,
    child: ItemTile(item: item),
  ),
)

The delay is a product decision rather than a performance optimisation. If it is too short, users trigger drags while trying to scroll; if it is too long, the interface feels inert. Keep maxSimultaneousDrags at one when duplicate simultaneous drags of a single card would make no sense.

Connect lifecycle callbacks to real interaction state

Source callbacks answer different questions from target callbacks. onDragStarted can reveal drop zones, while onDragUpdate reports movement. onDragCompleted runs only after a target accepts the data, making it suitable for removing an item from its original list when implementing a transfer.

onDraggableCanceled receives a velocity and offset when no target accepts the drop. Use it to restore transient state or record a rejected attempt, not to show an error for every exploratory movement. onDragEnd runs for both outcomes; its details include whether the drag was accepted. Avoid updating the same collection in both onAcceptWithDetails and onDragCompleted, or one gesture can cause two mutations.

Communicate acceptance without relying on colour alone

The target builder is rebuilt as candidates enter and leave, so it is the natural place for hover feedback. The worked example changes its border and background, but it also changes the icon and instruction text. That additional signal helps people who cannot distinguish the chosen colours and makes rejection easier to understand.

Drag-and-drop should not be the only way to perform an essential action. Keyboard users and assistive-technology users may not be able to reproduce a pointer drag, so provide an accompanying action such as an “Add to basket” button or a menu command. Give source items descriptive semantic labels, keep targets large enough to reach, and ensure the accepted result is announced or otherwise visible.

Desktop users may also expect the cursor to indicate that an item is movable. A MouseRegion can provide SystemMouseCursors.grab, but the cursor is a hint rather than a replacement for labels, focusable controls, or an alternative action.

Avoid the typing and state mistakes that break drops

  • Mismatched generic arguments: a Draggable<GroceryItem> cannot satisfy a DragTarget<String>. Use the same domain type at both ends rather than falling back to dynamic.
  • Missing data: a draggable without a meaningful payload cannot take part in the target's typed acceptance flow. Pass the item through data, even when the feedback already displays it.
  • Mutating during validation: onWillAcceptWithDetails may run more than once. It should calculate and return a boolean without adding or removing records.
  • Ignoring rejectedData: candidate data contains payloads the target is willing to accept; refused payloads appear separately. Reading only the accepted list produces no visual explanation for invalid items.
  • Unconstrained feedback: widgets such as ListTile need a finite width when drawn in the overlay. Wrap them in SizedBox or ConstrainedBox.
  • Keeping stale list indices: an index can change during a reorder. Carry the item or a stable identifier, then find its current position when the drop completes.

Verify one accepted drop and one refusal

A useful widget test moves a real gesture rather than calling callbacks directly. Save this under test/drag_drop_test.dart beside the application above. It proves that an apple reaches the basket while cupboard beans do not.

import 'package:flutter_test/flutter_test.dart';

import '../lib/main.dart';

void main() {
  testWidgets('accepts fresh food and rejects cupboard food',
      (tester) async {
    await tester.pumpWidget(const DragDropApp());

    Offset targetCentre() {
      return tester.getCenter(find.text('Drop fresh food here'));
    }

    final Offset appleStart =
        tester.getCenter(find.text('Apple').first);
    final Offset appleTarget = targetCentre();

    final TestGesture appleDrag =
        await tester.startGesture(appleStart);
    await appleDrag.moveTo(appleTarget);
    await tester.pump();
    await appleDrag.up();
    await tester.pump();

    expect(find.text('Basket: Apple'), findsOneWidget);

    final Offset beansStart =
        tester.getCenter(find.text('Beans').first);
    final Offset beansTarget = targetCentre();

    final TestGesture beansDrag =
        await tester.startGesture(beansStart);
    await beansDrag.moveTo(beansTarget);
    await tester.pump();

    expect(
      find.text('Only new fresh items can be added'),
      findsOneWidget,
    );

    await beansDrag.up();
    await tester.pump();

    expect(find.text('Basket: Beans'), findsNothing);
  });
}

Run the check with flutter test. On a physical device, also try slow movement, releasing at the target boundary, dragging while the page can scroll, and repeating an accepted item. Those checks expose hit-area, gesture-competition, and duplicate-state problems that a centred automated drag may not reveal.

Further reads

Keep going with these related tutorials from this site.