Swipe to Delete: Mastering Dismissible in Flutter
Build production-ready swipe-to-dismiss lists with Dismissible, covering keys, confirmDismiss vetoes, and correct list mutation.
Published on • November 2, 2026
AI Assistant

Swipe-to-delete is the most expected gesture in a mobile list, and Flutter ships a dedicated widget for it: Dismissible (docs.flutter.dev) — it handles drag tracking, background reveal, and the exit animation; you handle the data.
The Non-Negotiable: key
Every Dismissible must have a unique key. Without one Flutter cannot match children during list updates, and you get “A dismissed Dismissible widget is still part of the tree” exceptions:
Dismissible(
key: ValueKey(task.id),
child: TaskTile(task: task),
)
Backgrounds and Direction
background shows while swiping toward the start; secondaryBackground covers the reverse. direction defaults to DismissDirection.endToStart — variants include startToEnd, horizontal, vertical, and none:
Dismissible(
key: ValueKey(task.id),
direction: DismissDirection.endToStart,
background: Container(color: Colors.red, alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(Icons.delete, color: Colors.white)),
secondaryBackground: Container(color: Colors.red.shade700,
alignment: Alignment.centerLeft,
child: const Icon(Icons.delete_sweep, color: Colors.white)),
child: ListTile(title: Text(task.title)),
)
Vetoing a Dismiss with confirmDismiss
confirmDismiss is an async gate that runs before the widget leaves the tree — return false to snap it back. This is where confirmation dialogs belong:
confirmDismiss: (direction) async => await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete task?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('Delete')),
],
),
) ?? false,
confirmDismiss vs onDismissed
Order matters: confirmDismiss resolves first; only true triggers the exit animation and then onDismissed, which fires after the widget is gone. It must mutate the backing list — otherwise the element is still mounted and Flutter throws:
onDismissed: (_) {
setState(() => tasks.removeWhere((t) => t.id == task.id));
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Task deleted')));
},
Testing Tips
- Drag with
tester.drag(find.byType(Dismissible), const Offset(-500, 0)), thentester.pumpAndSettle(). - If
confirmDismissopens a dialog, pump it and tap its button before settling. - Assert the list length changed, not just that the widget vanished — that catches the missing-mutation bug.
Takeaway: key every Dismissible, gate risky deletes in confirmDismiss, and always remove the item from state in onDismissed.