Flutter Dismissible: Swipe-to-Delete List Items with Undo

Coding Liquids tutorial cover featuring Sagnik Bhattacharya for swipe-to-delete list items.
Coding Liquids tutorial cover featuring Sagnik Bhattacharya for swipe-to-delete list items.

A swipe-to-delete row looks simple until the wrong item disappears, an accidental gesture destroys data, or Flutter reports that a dismissed widget remains in the tree. In this tutorial, you will build a task list whose rows can be dismissed deliberately, confirmed before deletion, and restored from a SnackBar.

Follow me on Instagram@sagnikteaches

The implementation connects each Dismissible to stable data, gives opposite swipe directions distinct backgrounds, and updates the backing list through setState. It also covers DismissDirection, asynchronous confirmation, undo behaviour, and the lifecycle behind Flutter's common dismissal assertion.

Connect on LinkedInSagnik Bhattacharya

You only need a standard Flutter project and the Material library included with the SDK. The crucial idea is that Dismissible animates a widget away; your state still owns the data and must remove that data when the animation completes.

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

Give every dismissible row a stable identity

Flutter needs to match each visible row with the same logical item after the list changes. Pass a unique, stable Key to every Dismissible; an identifier stored in the model is a stronger choice than the current list index because indices shift as soon as an item is removed.

import 'package:flutter/material.dart';

@immutable
class Task {
  const Task({required this.id, required this.title});

  final String id;
  final String title;
}

Widget buildTaskRow(Task task) {
  return Dismissible(
    key: ValueKey<String>(task.id),
    onDismissed: (_) {},
    child: ListTile(title: Text(task.title)),
  );
}

Two rows must never share the same key. A title is only suitable when the application guarantees that titles are unique; a database ID or generated model ID is safer. Do not use ValueKey(index), because the item occupying an index changes after deletion. A fresh UniqueKey() inside build is also unhelpful: it tells Flutter that every row is new on every rebuild.

Remove the model item when the swipe finishes

The onDismissed callback runs after the row has crossed its dismissal threshold and completed the movement animation. Capture the task represented by the row, then remove that exact object from the list inside setState. The rebuild reduces itemCount and stops Flutter from constructing the dismissed row again.

import 'package:flutter/material.dart';

void main() => runApp(const BasicDismissibleApp());

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(home: BasicTaskPage());
  }
}

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

  @override
  State<BasicTaskPage> createState() => _BasicTaskPageState();
}

class _BasicTaskPageState extends State<BasicTaskPage> {
  final List<Task> tasks = [
    const Task(id: 'invoice', title: 'Send the invoice'),
    const Task(id: 'release', title: 'Review the release notes'),
    const Task(id: 'backup', title: 'Verify the backup'),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Tasks')),
      body: ListView.builder(
        itemCount: tasks.length,
        itemBuilder: (context, index) {
          final task = tasks[index];

          return Dismissible(
            key: ValueKey<String>(task.id),
            onDismissed: (_) {
              setState(() {
                tasks.remove(task);
              });
            },
            child: ListTile(title: Text(task.title)),
          );
        },
      ),
    );
  }
}

@immutable
class Task {
  const Task({required this.id, required this.title});

  final String id;
  final String title;
}

Removing by identity is resilient when indices might change between building the row and handling the callback. If your state layer deletes records asynchronously, remove or mark the local item immediately, attempt the persistence operation, and restore it if that operation fails. Leaving a successfully dismissed item in the source list is not a valid loading state.

Show the action behind each direction

The background is revealed when the child moves towards the end of the reading direction. secondaryBackground appears when the child moves towards the start, but it may only be supplied when background is also present. In a left-to-right interface, a start-to-end swipe travels right and reveals background; an end-to-start swipe travels left and reveals secondaryBackground.

Dismissible(
  key: ValueKey<String>(task.id),
  background: Container(
    color: Colors.green.shade700,
    alignment: Alignment.centerLeft,
    padding: const EdgeInsets.symmetric(horizontal: 20),
    child: const Icon(
      Icons.check,
      color: Colors.white,
      semanticLabel: 'Complete task',
    ),
  ),
  secondaryBackground: Container(
    color: Colors.red.shade700,
    alignment: Alignment.centerRight,
    padding: const EdgeInsets.symmetric(horizontal: 20),
    child: const Icon(
      Icons.delete,
      color: Colors.white,
      semanticLabel: 'Delete task',
    ),
  ),
  onDismissed: (direction) {
    setState(() {
      tasks.remove(task);
    });
  },
  child: ListTile(title: Text(task.title)),
)

Align the icon to the edge exposed by the gesture, not the direction in which the row ends. Flutter interprets start and end using Directionality, so this layout also adapts when the application uses a right-to-left locale. Colour should reinforce the action rather than carry its meaning alone; the labelled icons provide an additional cue.

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

Restrict the gesture with DismissDirection

The direction property determines which gestures can dismiss the child. DismissDirection.endToStart is a common delete-only choice because it blocks the opposite swipe completely. startToEnd provides the reverse action, while horizontal permits both. Vertical lists can use up, down, or vertical, although horizontal dismissal usually conflicts less with scrolling.

Dismissible(
  key: ValueKey<String>(task.id),
  direction: DismissDirection.endToStart,
  background: Container(
    color: Colors.red.shade700,
    alignment: Alignment.centerRight,
    padding: const EdgeInsets.symmetric(horizontal: 20),
    child: const Icon(
      Icons.delete,
      color: Colors.white,
      semanticLabel: 'Delete task',
    ),
  ),
  onDismissed: (_) {
    setState(() {
      tasks.remove(task);
    });
  },
  child: ListTile(title: Text(task.title)),
)

Use DismissDirection.none when a row must temporarily remain fixed, such as while its server update is pending. The dismissThresholds property can require a longer or shorter drag, but confirmation and undo are better safeguards than making the gesture frustratingly long.

Pause deletion for an asynchronous confirmation

confirmDismiss receives the attempted direction before the final dismissal animation. Its Future<bool?> result controls the outcome: true continues, while false or null returns the child to its original position. This makes an AlertDialog suitable for destructive actions without putting dialog logic inside onDismissed.

Future<bool> confirmDelete(
  BuildContext context,
  Task task,
) async {
  final approved = await showDialog<bool>(
    context: context,
    builder: (dialogContext) {
      return AlertDialog(
        title: const Text('Delete task?'),
        content: Text(
          '"${task.title}" will be removed from this list.',
        ),
        actions: [
          TextButton(
            onPressed: () {
              Navigator.of(dialogContext).pop(false);
            },
            child: const Text('Cancel'),
          ),
          FilledButton(
            onPressed: () {
              Navigator.of(dialogContext).pop(true);
            },
            child: const Text('Delete'),
          ),
        ],
      );
    },
  );

  return approved ?? false;
}

Using approved ?? false treats tapping outside the dialog, pressing the system back button, or otherwise closing it without a value as cancellation. While the future is unresolved, Flutter prevents another drag on that Dismissible. Keep irreversible storage or network deletion out of confirmDismiss: confirmation answers whether the gesture may proceed, whereas onDismissed performs the resulting state change.

Combine two swipe actions, confirmation, and undo

The following complete application treats a start-to-end swipe as completing a task and an end-to-start swipe as deletion. Only deletion opens the dialog. Both actions remove the row, retain its former index, and offer one opportunity to put it back.

import 'package:flutter/material.dart';

void main() => runApp(const SwipeTaskApp());

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

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

@immutable
class Task {
  const Task({required this.id, required this.title});

  final String id;
  final String title;
}

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

  @override
  State<TaskPage> createState() => _TaskPageState();
}

class _TaskPageState extends State<TaskPage> {
  final List<Task> _tasks = [
    const Task(id: 'accessibility', title: 'Check accessibility labels'),
    const Task(id: 'screenshots', title: 'Capture store screenshots'),
    const Task(id: 'release', title: 'Publish the release notes'),
  ];

  Future<bool> _confirmAction(
    DismissDirection direction,
    Task task,
  ) async {
    if (direction == DismissDirection.startToEnd) {
      return true;
    }

    final approved = await showDialog<bool>(
      context: context,
      builder: (dialogContext) {
        return AlertDialog(
          title: const Text('Delete task?'),
          content: Text(
            '"${task.title}" will be removed.',
          ),
          actions: [
            TextButton(
              onPressed: () {
                Navigator.of(dialogContext).pop(false);
              },
              child: const Text('Cancel'),
            ),
            FilledButton(
              onPressed: () {
                Navigator.of(dialogContext).pop(true);
              },
              child: const Text('Delete'),
            ),
          ],
        );
      },
    );

    return approved ?? false;
  }

  void _removeWithUndo(
    Task task,
    int oldIndex,
    DismissDirection direction,
  ) {
    setState(() {
      _tasks.remove(task);
    });

    final action = direction == DismissDirection.endToStart
        ? 'Deleted'
        : 'Completed';
    final messenger = ScaffoldMessenger.of(context);

    messenger.clearSnackBars();
    messenger.showSnackBar(
      SnackBar(
        content: Text('$action "${task.title}"'),
        action: SnackBarAction(
          label: 'Undo',
          onPressed: () {
            if (!mounted) {
              return;
            }

            setState(() {
              final insertionIndex = oldIndex > _tasks.length
                  ? _tasks.length
                  : oldIndex;
              _tasks.insert(insertionIndex, task);
            });
          },
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Release tasks')),
      body: _tasks.isEmpty
          ? const Center(child: Text('No tasks remaining'))
          : ListView.builder(
              itemCount: _tasks.length,
              itemBuilder: (context, index) {
                final task = _tasks[index];

                return Dismissible(
                  key: ValueKey<String>(task.id),
                  direction: DismissDirection.horizontal,
                  confirmDismiss: (direction) {
                    return _confirmAction(direction, task);
                  },
                  onDismissed: (direction) {
                    _removeWithUndo(task, index, direction);
                  },
                  background: Container(
                    color: Colors.green.shade700,
                    alignment: Alignment.centerLeft,
                    padding: const EdgeInsets.symmetric(horizontal: 20),
                    child: const Icon(
                      Icons.check,
                      color: Colors.white,
                      semanticLabel: 'Complete task',
                    ),
                  ),
                  secondaryBackground: Container(
                    color: Colors.red.shade700,
                    alignment: Alignment.centerRight,
                    padding: const EdgeInsets.symmetric(horizontal: 20),
                    child: const Icon(
                      Icons.delete,
                      color: Colors.white,
                      semanticLabel: 'Delete task',
                    ),
                  ),
                  child: ListTile(
                    title: Text(task.title),
                    subtitle: const Text(
                      'Swipe right to complete or left to delete',
                    ),
                  ),
                );
              },
            ),
    );
  }
}

Clearing existing SnackBars gives this example a single active undo operation. Applications that must support several rapid dismissals should store pending removals by ID and queue their messages instead. Reinserting at the bounded old index preserves the previous ordering even when other rows have changed during the SnackBar's lifetime.

Why the dismissed-widget assertion appears

The message A dismissed Dismissible widget is still part of the tree means the animation finished and onDismissed ran, but the subsequent build produced a Dismissible with the same key. Hiding its child, changing its opacity, or setting a local flag inside the row does not repair the data-list contract.

onDismissed: (_) {
  setState(() {
    tasks.removeWhere((candidate) => candidate.id == task.id);
  });
}

Remove the model entry used by itemCount and itemBuilder. If state lives in a provider, bloc, notifier, or repository, dispatch the removal there and ensure its emitted state excludes the item immediately. Also check that the key really belongs to the model: duplicate keys and index keys can make Flutter associate the completed animation with a different row.

Avoid deletion and gesture traps

  • Removing only the rendered child: update the collection that generates the list instead.
  • Mutating without setState: changing a local List does not schedule a StatefulWidget rebuild; wrap the mutation in setState or emit new state through the chosen state-management layer.
  • Deleting inside confirmDismiss: if it returns true, onDismissed will still run. Keep the actual removal in one place to avoid double deletion.
  • Using the captured index as identity: asynchronous work may make that index stale. Delete by ID or object identity and use the old index only as an undo position.
  • Offering two directions with one misleading background: pair background and secondaryBackground with the action each direction performs.
  • Ignoring persistence failures: restore the task and report the failure if a database or server rejects deletion.

For highly destructive data, confirmation may still be insufficient because users quickly learn to approve repeated dialogs. A reversible soft delete, delayed server commit, or dedicated archive can provide a stronger recovery path than a brief SnackBar.

Verify that a completed swipe changes the source list

A widget test should drag a row beyond its threshold, settle both dismissal phases, and assert that the keyed item is absent. This compact test includes its own stateful harness, so it can run as a complete file under test/dismissible_test.dart.

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('a completed swipe removes the backing item', (tester) async {
    final items = <String>['alpha', 'beta'];

    await tester.pumpWidget(
      MaterialApp(
        home: Scaffold(
          body: StatefulBuilder(
            builder: (context, setState) {
              return ListView(
                children: [
                  for (final item in items)
                    Dismissible(
                      key: ValueKey<String>(item),
                      direction: DismissDirection.endToStart,
                      onDismissed: (_) {
                        setState(() {
                          items.remove(item);
                        });
                      },
                      child: ListTile(title: Text(item)),
                    ),
                ],
              );
            },
          ),
        ),
      ),
    );

    await tester.drag(
      find.byKey(const ValueKey<String>('alpha')),
      const Offset(-500, 0),
    );
    await tester.pumpAndSettle();

    expect(items, equals(<String>['beta']));
    expect(find.text('alpha'), findsNothing);
    expect(find.text('beta'), findsOneWidget);
  });
}

Add a second test for a cancelled confirmDismiss callback by returning Future.value(false); after the drag settles, both the model and row should remain. Manually verify short drags, both text directions, dialog cancellation through the system back action, rapid consecutive swipes, and undo after another item has changed position.

Further reads

Keep going with these related tutorials from this site.