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.
Prerequisites
- 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
Create a sortable list with ReorderableListView
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.
1// Custom Widget: SortableTaskList2import 'package:flutter/material.dart';34class SortableTaskList extends StatefulWidget {5 const SortableTaskList({6 super.key,7 required this.tasks,8 required this.onReorderComplete,9 });1011 final List<dynamic> tasks;12 final Future<dynamic> Function(List<dynamic>) onReorderComplete;1314 @override15 State<SortableTaskList> createState() => _SortableTaskListState();16}1718class _SortableTaskListState extends State<SortableTaskList> {19 late List<dynamic> _items;2021 @override22 void initState() {23 super.initState();24 _items = List.from(widget.tasks);25 }2627 void _onReorder(int oldIndex, int newIndex) {28 setState(() {29 if (newIndex > oldIndex) newIndex--;30 final item = _items.removeAt(oldIndex);31 _items.insert(newIndex, item);32 });33 // Only write to Firestore AFTER reorder is complete34 widget.onReorderComplete(_items);35 }3637 @override38 Widget build(BuildContext context) {39 return ReorderableListView(40 onReorder: _onReorder,41 children: [42 for (int i = 0; i < _items.length; i++)43 ListTile(44 key: ValueKey(_items[i]['id']),45 title: Text(_items[i]['title'] ?? ''),46 trailing: const Icon(Icons.drag_handle),47 ),48 ],49 );50 }51}Expected result: A list where tasks can be dragged up and down to reorder. The onReorderComplete callback fires once when the drag ends.
Create a Cloud Function to save the new sort order
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.
1// functions/index.js2exports.updateTaskOrder = functions.https.onCall(async (data, context) => {3 if (!context.auth) throw new functions.https.HttpsError('unauthenticated', 'Login required');45 const { taskIds } = data; // array of Firestore doc IDs in new order6 if (!Array.isArray(taskIds) || taskIds.length === 0) {7 throw new functions.https.HttpsError('invalid-argument', 'taskIds must be a non-empty array');8 }910 const db = admin.firestore();11 const batch = db.batch();1213 taskIds.forEach((id, index) => {14 const ref = db.collection('tasks').doc(id);15 batch.update(ref, { sortOrder: index });16 });1718 await batch.commit();19 return { success: true };20});Expected result: Cloud Function deployed. After a reorder, all affected task documents have updated sortOrder values in Firestore within 500ms.
Build a Kanban board with Draggable and DragTarget
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.
1// Custom Widget: KanbanBoard (simplified single-column DragTarget)2import 'package:flutter/material.dart';34class KanbanColumn extends StatefulWidget {5 const KanbanColumn({6 super.key,7 required this.status,8 required this.tasks,9 required this.onTaskMoved,10 });1112 final String status;13 final List<dynamic> tasks;14 final void Function(String taskId, String newStatus) onTaskMoved;1516 @override17 State<KanbanColumn> createState() => _KanbanColumnState();18}1920class _KanbanColumnState extends State<KanbanColumn> {21 bool _isDragOver = false;2223 @override24 Widget build(BuildContext context) {25 return DragTarget<String>(26 onWillAcceptWithDetails: (details) => true,27 onAcceptWithDetails: (details) {28 setState(() => _isDragOver = false);29 widget.onTaskMoved(details.data, widget.status);30 },31 onMove: (_) => setState(() => _isDragOver = true),32 onLeave: (_) => setState(() => _isDragOver = false),33 builder: (context, candidateData, rejectedData) {34 return AnimatedContainer(35 duration: const Duration(milliseconds: 150),36 color: _isDragOver37 ? Colors.blue.withOpacity(0.15)38 : Colors.grey.withOpacity(0.05),39 padding: const EdgeInsets.all(8),40 child: Column(41 children: [42 Text(widget.status, style: const TextStyle(fontWeight: FontWeight.bold)),43 ...widget.tasks.map((task) => Draggable<String>(44 data: task['id'] as String,45 feedback: Material(46 elevation: 8,47 child: Container(48 padding: const EdgeInsets.all(12),49 width: 200,50 color: Colors.white,51 child: Text(task['title'] ?? ''),52 ),53 ),54 childWhenDragging: Opacity(55 opacity: 0.4,56 child: _TaskCard(task: task),57 ),58 child: _TaskCard(task: task),59 )),60 ],61 ),62 );63 },64 );65 }66}6768class _TaskCard extends StatelessWidget {69 const _TaskCard({required this.task});70 final dynamic task;7172 @override73 Widget build(BuildContext context) {74 return Card(75 margin: const EdgeInsets.symmetric(vertical: 4),76 child: Padding(77 padding: const EdgeInsets.all(12),78 child: Text(task['title'] ?? ''),79 ),80 );81 }82}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.
Wire the Custom Widget to FlutterFlow pages and Firestore
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 working example
1// FlutterFlow Custom Widget: SortableList2// A full-featured ReorderableListView with Firestore integration3// Parameters: tasks (JSON List), onReorderComplete (Action returning List)45import 'package:flutter/material.dart';67class SortableList extends StatefulWidget {8 const SortableList({9 super.key,10 required this.tasks,11 required this.onReorderComplete,12 this.itemColor,13 this.dragHandleColor,14 });1516 final List<dynamic> tasks;17 final Future<dynamic> Function(List<dynamic>) onReorderComplete;18 final Color? itemColor;19 final Color? dragHandleColor;2021 @override22 State<SortableList> createState() => _SortableListState();23}2425class _SortableListState extends State<SortableList> {26 late List<dynamic> _items;2728 @override29 void initState() {30 super.initState();31 _items = List<dynamic>.from(widget.tasks);32 }3334 @override35 void didUpdateWidget(SortableList oldWidget) {36 super.didUpdateWidget(oldWidget);37 // Sync if the parent data changes (e.g. after Firestore refresh)38 if (oldWidget.tasks != widget.tasks) {39 _items = List<dynamic>.from(widget.tasks);40 }41 }4243 void _onReorder(int oldIndex, int newIndex) {44 setState(() {45 if (newIndex > oldIndex) newIndex--;46 final item = _items.removeAt(oldIndex);47 _items.insert(newIndex, item);48 });49 // Write to Firestore only once — after the drag is complete50 widget.onReorderComplete(_items);51 }5253 @override54 Widget build(BuildContext context) {55 return ReorderableListView(56 shrinkWrap: true,57 onReorder: _onReorder,58 proxyDecorator: (child, index, animation) {59 return AnimatedBuilder(60 animation: animation,61 builder: (context, child) => Material(62 elevation: 8 * animation.value,63 borderRadius: BorderRadius.circular(8),64 child: child,65 ),66 child: child,67 );68 },69 children: [70 for (final item in _items)71 Container(72 key: ValueKey(item['id']),73 margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),74 decoration: BoxDecoration(75 color: widget.itemColor ?? Colors.white,76 borderRadius: BorderRadius.circular(8),77 boxShadow: [78 BoxShadow(79 color: Colors.black.withOpacity(0.05),80 blurRadius: 4,81 offset: const Offset(0, 2),82 )83 ],84 ),85 child: ListTile(86 title: Text(item['title'] ?? ''),87 subtitle: item['subtitle'] != null88 ? Text(item['subtitle'])89 : null,90 trailing: Icon(91 Icons.drag_handle,92 color: widget.dragHandleColor ?? Colors.grey,93 ),94 ),95 ),96 ],97 );98 }99}Common mistakes when creating a Drag-and-Drop Interface in FlutterFlow
Why it's a problem: Updating Firestore on every pixel of drag movement
How to avoid: Only write to Firestore inside onReorder (ReorderableListView) or onAcceptWithDetails (DragTarget) — these fire exactly once when the drag ends and the item is placed.
Why it's a problem: Using index-based keys (ValueKey(index)) instead of ID-based keys
How to avoid: Always use the document's unique Firestore ID as the key: ValueKey(item['id']). This remains stable regardless of position.
Why it's a problem: Not wrapping Draggable feedback in a Material widget
How to avoid: 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
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I'm building a FlutterFlow app with a Kanban board. Write a Flutter Custom Widget using Draggable and DragTarget that shows three columns (Todo, In Progress, Done). Task cards should be draggable between columns. When a card is dropped, call a callback with the task ID and the new column status. Include visual feedback: highlight the drop target column and show an elevated ghost card under the finger during drag.
In FlutterFlow, I have a Custom Widget (SortableList) that calls onReorderComplete with the reordered list when a drag ends. How do I wire this callback in FlutterFlow's widget parameter settings to trigger a Custom Action that loops through the list and updates the sortOrder field on each Firestore task document?
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation