# How to Create a Drag-and-Drop Interface in FlutterFlow

- Tool: FlutterFlow
- Difficulty: Beginner
- Time required: 45-60 min
- Compatibility: FlutterFlow Pro+ (Custom Widgets required)
- Last updated: March 2026

## TL;DR

FlutterFlow supports drag-and-drop through two Flutter patterns: ReorderableListView for sorting items within a single list, and Draggable with DragTarget for moving items between different containers like a Kanban board. Both require Custom Widgets. The critical rule is to only save the new order to Firestore when the drag ends — not during the drag.

## Two drag-and-drop patterns — which one to use

Flutter offers two distinct drag-and-drop widgets. ReorderableListView is the simplest: it handles all drag logic internally and calls onReorder when the user lifts their finger, giving you the old and new index. It's perfect for sorting a to-do list, playlist, or ranked items within one list. Draggable and DragTarget are lower-level and more flexible: any widget can be dragged, and you define which areas accept drops. Use this pattern for Kanban boards (drag tasks between status columns), file drag-to-folder interfaces, or any interaction where items move between distinct zones. Both patterns require Custom Widgets in FlutterFlow, which need the Pro plan.

## Before you start

- FlutterFlow Pro plan for Custom Widgets
- FlutterFlow project with Firebase Firestore connected
- A collection in Firestore for your draggable items (e.g., tasks with a 'sortOrder' field)
- Basic understanding of FlutterFlow's Custom Code section

## Step-by-step guide

### 1. Create a sortable list with ReorderableListView

Create a new Custom Widget named 'SortableTaskList'. It accepts a list of task maps (each with id, title, and sortOrder fields) and a callback function to save the new order. The widget displays a ReorderableListView. When the user drags an item to a new position, onReorder fires with the old and new index. Rearrange the local list, then call the save callback — not during the drag. Paste the code below into your Custom Widget editor. Add 'tasks' (JSON list) and 'onReorderComplete' (Action) as widget parameters.

```
// Custom Widget: SortableTaskList
import 'package:flutter/material.dart';

class SortableTaskList extends StatefulWidget {
  const SortableTaskList({
    super.key,
    required this.tasks,
    required this.onReorderComplete,
  });

  final List<dynamic> tasks;
  final Future<dynamic> Function(List<dynamic>) onReorderComplete;

  @override
  State<SortableTaskList> createState() => _SortableTaskListState();
}

class _SortableTaskListState extends State<SortableTaskList> {
  late List<dynamic> _items;

  @override
  void initState() {
    super.initState();
    _items = List.from(widget.tasks);
  }

  void _onReorder(int oldIndex, int newIndex) {
    setState(() {
      if (newIndex > oldIndex) newIndex--;
      final item = _items.removeAt(oldIndex);
      _items.insert(newIndex, item);
    });
    // Only write to Firestore AFTER reorder is complete
    widget.onReorderComplete(_items);
  }

  @override
  Widget build(BuildContext context) {
    return ReorderableListView(
      onReorder: _onReorder,
      children: [
        for (int i = 0; i < _items.length; i++)
          ListTile(
            key: ValueKey(_items[i]['id']),
            title: Text(_items[i]['title'] ?? ''),
            trailing: const Icon(Icons.drag_handle),
          ),
      ],
    );
  }
}
```

**Expected result:** A list where tasks can be dragged up and down to reorder. The onReorderComplete callback fires once when the drag ends.

### 2. Create a Cloud Function to save the new sort order

When onReorderComplete fires, you need to update sortOrder on every task document. A Firestore batch write is the efficient way — it updates all documents atomically in one network round trip. Create a Cloud Function named 'updateTaskOrder'. It accepts an array of task IDs in their new order and writes the sortOrder index (0, 1, 2...) back to each Firestore document. Call this from a FlutterFlow Custom Action that wraps the HTTP call.

```
// functions/index.js
exports.updateTaskOrder = functions.https.onCall(async (data, context) => {
  if (!context.auth) throw new functions.https.HttpsError('unauthenticated', 'Login required');

  const { taskIds } = data; // array of Firestore doc IDs in new order
  if (!Array.isArray(taskIds) || taskIds.length === 0) {
    throw new functions.https.HttpsError('invalid-argument', 'taskIds must be a non-empty array');
  }

  const db = admin.firestore();
  const batch = db.batch();

  taskIds.forEach((id, index) => {
    const ref = db.collection('tasks').doc(id);
    batch.update(ref, { sortOrder: index });
  });

  await batch.commit();
  return { success: true };
});
```

**Expected result:** Cloud Function deployed. After a reorder, all affected task documents have updated sortOrder values in Firestore within 500ms.

### 3. Build a Kanban board with Draggable and DragTarget

For a Kanban board where tasks move between status columns (Todo, In Progress, Done), use Draggable and DragTarget. Create a Custom Widget named 'KanbanBoard'. Each column is a DragTarget. Each task card is a Draggable that carries the task ID as its data. When a task is dropped on a column, onAcceptWithDetails fires with the dragged task ID and the column's status. The widget updates the local state immediately for instant visual feedback, then calls the save callback.

```
// Custom Widget: KanbanBoard (simplified single-column DragTarget)
import 'package:flutter/material.dart';

class KanbanColumn extends StatefulWidget {
  const KanbanColumn({
    super.key,
    required this.status,
    required this.tasks,
    required this.onTaskMoved,
  });

  final String status;
  final List<dynamic> tasks;
  final void Function(String taskId, String newStatus) onTaskMoved;

  @override
  State<KanbanColumn> createState() => _KanbanColumnState();
}

class _KanbanColumnState extends State<KanbanColumn> {
  bool _isDragOver = false;

  @override
  Widget build(BuildContext context) {
    return DragTarget<String>(
      onWillAcceptWithDetails: (details) => true,
      onAcceptWithDetails: (details) {
        setState(() => _isDragOver = false);
        widget.onTaskMoved(details.data, widget.status);
      },
      onMove: (_) => setState(() => _isDragOver = true),
      onLeave: (_) => setState(() => _isDragOver = false),
      builder: (context, candidateData, rejectedData) {
        return AnimatedContainer(
          duration: const Duration(milliseconds: 150),
          color: _isDragOver
              ? Colors.blue.withOpacity(0.15)
              : Colors.grey.withOpacity(0.05),
          padding: const EdgeInsets.all(8),
          child: Column(
            children: [
              Text(widget.status, style: const TextStyle(fontWeight: FontWeight.bold)),
              ...widget.tasks.map((task) => Draggable<String>(
                data: task['id'] as String,
                feedback: Material(
                  elevation: 8,
                  child: Container(
                    padding: const EdgeInsets.all(12),
                    width: 200,
                    color: Colors.white,
                    child: Text(task['title'] ?? ''),
                  ),
                ),
                childWhenDragging: Opacity(
                  opacity: 0.4,
                  child: _TaskCard(task: task),
                ),
                child: _TaskCard(task: task),
              )),
            ],
          ),
        );
      },
    );
  }
}

class _TaskCard extends StatelessWidget {
  const _TaskCard({required this.task});
  final dynamic task;

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.symmetric(vertical: 4),
      child: Padding(
        padding: const EdgeInsets.all(12),
        child: Text(task['title'] ?? ''),
      ),
    );
  }
}
```

**Expected result:** Task cards can be dragged from one column to another. The destination column highlights on hover. Dropping the card calls onTaskMoved with the task ID and new status.

### 4. Wire the Custom Widget to FlutterFlow pages and Firestore

Add the KanbanBoard Custom Widget to your page. Create a Backend Query at the page level loading tasks from Firestore ordered by sortOrder. Pass the tasks list to the widget. In the widget's onTaskMoved parameter, connect a Custom Action that calls your Firestore update: update the task document's status field to the new column status. Use a Firestore Update Document action within the Action Flow — target the tasks collection, identify the document by the task ID passed from the widget, and update the status field.

**Expected result:** Moving a card between columns immediately updates the UI and writes the new status to Firestore. The page reflects the correct state after reload.

## Complete code example

File: `reorderable_list_widget.dart`

```dart
// FlutterFlow Custom Widget: SortableList
// A full-featured ReorderableListView with Firestore integration
// Parameters: tasks (JSON List), onReorderComplete (Action returning List)

import 'package:flutter/material.dart';

class SortableList extends StatefulWidget {
  const SortableList({
    super.key,
    required this.tasks,
    required this.onReorderComplete,
    this.itemColor,
    this.dragHandleColor,
  });

  final List<dynamic> tasks;
  final Future<dynamic> Function(List<dynamic>) onReorderComplete;
  final Color? itemColor;
  final Color? dragHandleColor;

  @override
  State<SortableList> createState() => _SortableListState();
}

class _SortableListState extends State<SortableList> {
  late List<dynamic> _items;

  @override
  void initState() {
    super.initState();
    _items = List<dynamic>.from(widget.tasks);
  }

  @override
  void didUpdateWidget(SortableList oldWidget) {
    super.didUpdateWidget(oldWidget);
    // Sync if the parent data changes (e.g. after Firestore refresh)
    if (oldWidget.tasks != widget.tasks) {
      _items = List<dynamic>.from(widget.tasks);
    }
  }

  void _onReorder(int oldIndex, int newIndex) {
    setState(() {
      if (newIndex > oldIndex) newIndex--;
      final item = _items.removeAt(oldIndex);
      _items.insert(newIndex, item);
    });
    // Write to Firestore only once — after the drag is complete
    widget.onReorderComplete(_items);
  }

  @override
  Widget build(BuildContext context) {
    return ReorderableListView(
      shrinkWrap: true,
      onReorder: _onReorder,
      proxyDecorator: (child, index, animation) {
        return AnimatedBuilder(
          animation: animation,
          builder: (context, child) => Material(
            elevation: 8 * animation.value,
            borderRadius: BorderRadius.circular(8),
            child: child,
          ),
          child: child,
        );
      },
      children: [
        for (final item in _items)
          Container(
            key: ValueKey(item['id']),
            margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
            decoration: BoxDecoration(
              color: widget.itemColor ?? Colors.white,
              borderRadius: BorderRadius.circular(8),
              boxShadow: [
                BoxShadow(
                  color: Colors.black.withOpacity(0.05),
                  blurRadius: 4,
                  offset: const Offset(0, 2),
                )
              ],
            ),
            child: ListTile(
              title: Text(item['title'] ?? ''),
              subtitle: item['subtitle'] != null
                  ? Text(item['subtitle'])
                  : null,
              trailing: Icon(
                Icons.drag_handle,
                color: widget.dragHandleColor ?? Colors.grey,
              ),
            ),
          ),
      ],
    );
  }
}
```

## Common mistakes

- **Updating Firestore on every pixel of drag movement** — If you call a Firestore update inside onMove or a drag position callback, you generate hundreds of database writes per second during a single drag. At Firestore's pricing of $0.18/100K writes, a few minutes of active dragging creates significant costs and likely exhausts your daily write quota. Fix: Only write to Firestore inside onReorder (ReorderableListView) or onAcceptWithDetails (DragTarget) — these fire exactly once when the drag ends and the item is placed.
- **Using index-based keys (ValueKey(index)) instead of ID-based keys** — When ReorderableListView reorders items, the indexes change. Index-based keys cause Flutter's widget reconciliation to misidentify which widget moved, resulting in visual glitches and incorrect reorder behavior. Fix: Always use the document's unique Firestore ID as the key: ValueKey(item['id']). This remains stable regardless of position.
- **Not wrapping Draggable feedback in a Material widget** — The feedback widget floats above all other widgets. Without a Material ancestor, it loses elevation, shadows, and text rendering context, appearing as flat, unstyled content. Fix: Wrap the feedback widget in Material with an elevation value (4-8) to give it a raised, floating appearance during drag.

## Best practices

- Use ReorderableListView for single-list sorting, Draggable+DragTarget for cross-container movement
- Always batch Firestore order updates — one batch.commit() for all changed documents
- Provide visual drag feedback: elevation on the dragged item, highlight color on valid drop targets
- Show the original item at 40% opacity (childWhenDragging) so users maintain spatial context
- Update local state immediately for optimistic UI, then sync Firestore in the background
- Limit draggable lists to 200 items maximum — beyond that, virtual scrolling is needed for performance
- Test drag interactions with both finger and stylus on tablet-sized screens

## Frequently asked questions

### Does FlutterFlow have a built-in drag-and-drop widget?

No. FlutterFlow's visual editor does not expose Flutter's ReorderableListView, Draggable, or DragTarget widgets directly. You must implement drag-and-drop through Custom Widgets, which require the Pro plan ($70/mo).

### Can I use ReorderableListView and DragTarget together in the same app?

Yes. Use ReorderableListView for sorting within a column and DragTarget for moving items between columns. You can even nest them — a Kanban board where each column is a DragTarget and each column's internal list is a ReorderableListView.

### How do I persist the sort order when the app restarts?

Store a sortOrder integer field on each Firestore document. Update it with a batch write when the user finishes reordering. Load documents with .orderBy('sortOrder', 'asc') in your Backend Query. The list will reload in the correct order on every app start.

### Will drag-and-drop work on Flutter web?

Yes. Flutter web supports mouse drag using the same Draggable and DragTarget APIs. On web, dragging uses mouse pointer events instead of touch events — the same code works across both platforms without changes.

### How do I prevent some items from being draggable while others are?

In ReorderableListView, you cannot selectively disable reordering on specific items without a custom header widget pattern. In Draggable, wrap the non-draggable items in a plain widget without the Draggable wrapper — or set the Draggable's child to ignore pointer events when dragging should be disabled.

### Can I add drag-and-drop without exporting the project?

Yes, for basic cases. Add the Custom Widget code directly in FlutterFlow's Custom Code section — you do not need to export the project. FlutterFlow compiles and runs Custom Widgets in Run Mode. You only need to export if you require packages not available via FlutterFlow's pubspec dependencies settings.

---

Source: https://www.rapidevelopers.com/flutterflow-tutorials/how-to-create-a-drag-and-drop-interface-in-flutterflow
© RapidDev — https://www.rapidevelopers.com/flutterflow-tutorials/how-to-create-a-drag-and-drop-interface-in-flutterflow
