Skip to content
Blog

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)), then tester.pumpAndSettle().
  • If confirmDismiss opens 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.