skip to content

In a Flutter list of saved stickers, why does swiping a Dismissible assert that it is still part of the tree, and how do key, confirmDismiss and onDismissed fix it?

level: seniorimportance: should knowfreq 34%

answer

  1. widget is a view, the list is data
  2. remove synchronously in onDismissed
  3. stable ValueKey, never the index
  4. confirmDismiss: false or null returns
  5. threshold 0.4, resize 300 ms

basics

~20 s

After its resize animation, Dismissible calls onDismissed and expects to disappear; if the item is not removed from the data in that same frame, the next build asserts. Give it a stable key from the item's id, and put any async confirmation in confirmDismiss.

solid answer

~40 s

`Dismissible` slides its child away, collapses the gap over `resizeDuration` (300 ms by default) and then calls `onDismissed`; from then on it must not appear in the next build, or a debug assertion says a dismissed `Dismissible` is still part of the tree. So `onDismissed` has to remove the item from the list synchronously in `setState` or the state library, not after an `await` or when an undo snackbar expires. The required `key` must come from the item's identity, such as `ValueKey(item.id)`, never the index, or shifted rows inherit the wrong state. Anything asynchronous, a confirmation dialog or a server delete, belongs in `confirmDismiss`, whose `false` or `null` result slides the row back.

code

dart · 67 lines
dart
import 'package:flutter/material.dart';

class SavedSticker {
  const SavedSticker(this.id, this.name);
  final String id;
  final String name;
}

class SavedStickerList extends StatefulWidget {
  const SavedStickerList({super.key, required this.initial});

  final List<SavedSticker> initial;

  @override
  State<SavedStickerList> createState() => _SavedStickerListState();
}

class _SavedStickerListState extends State<SavedStickerList> {
  late final List<SavedSticker> _items = [...widget.initial];

  Future<bool?> _confirm(BuildContext context, SavedSticker item) {
    return showDialog<bool>(
      context: context,
      builder: (dialogContext) => AlertDialog(
        title: Text('Delete ${item.name}?'),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(dialogContext, false),
            child: const Text('Keep'),
          ),
          TextButton(
            onPressed: () => Navigator.pop(dialogContext, true),
            child: const Text('Delete'),
          ),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    final ColorScheme scheme = Theme.of(context).colorScheme;
    return ListView.builder(
      itemCount: _items.length,
      itemBuilder: (context, index) {
        final SavedSticker item = _items[index];
        return Dismissible(
          key: ValueKey(item.id), // stable identity, never the index
          direction: DismissDirection.endToStart,
          background: ColoredBox(
            color: scheme.errorContainer,
            child: const Align(
              alignment: AlignmentDirectional.centerEnd,
              child: Padding(padding: EdgeInsets.all(16), child: Icon(Icons.delete)),
            ),
          ),
          confirmDismiss: (_) => _confirm(context, item), // null counts as false
          onDismissed: (_) {
            // Remove from the data right away, or the next build asserts.
            setState(() => _items.removeWhere((s) => s.id == item.id));
          },
          child: ListTile(title: Text(item.name)),
        );
      },
    );
  }
}

go deeper

for a junior

Remember that onDismissed must remove the item from your list, and that every Dismissible needs a key tied to the item.

for a middle

Explain the move and resize animations, why the assertion fires, the default threshold and fling rules, and the null-means-false rule of confirmDismiss.

for a senior

Design delete flows with optimistic removal plus undo or confirmation via confirmDismiss, avoid context after await, and offer a non-swipe alternative.

for a principal

Set the product rule for destructive swipes, confirm versus undo, and how server failures roll back, so every list behaves the same.

## How `Dismissible` works `Dismissible` wraps a list item so the user can swipe it away. Internally it owns two animations: 1. A **move** animation that follows the drag and, on release, either slides the child off-screen or back into place. 2. A **resize** animation (`resizeDuration`, default 300 ms) that collapses the now-empty slot so the rows below close the gap. When the resize finishes, it calls **`onDismissed(direction)`**. From that moment the widget considers itself gone. If the next build still contains the same `Dismissible`, a debug assertion fails with *"A dismissed Dismissible widget is still part of the tree."* and the hint *"Make sure to implement the onDismissed handler and to immediately remove the Dismissible widget from the application once that handler has fired."* ## Why the error appears The widget is only a view; the list is your data. Common causes: - `onDismissed` is empty or only shows a snackbar, so the item stays in the list. - The removal happens **later** — after an `await` for a network call, or only when an undo snackbar times out — so at least one build still contains the dismissed widget. - The `key` is **not stable**: `ValueKey(index)` or `UniqueKey()`. After a removal, indexes shift, so a different item inherits the dismissed item's state and Flutter's element matching goes wrong. `Dismissible` takes a **required** `key` precisely because it must be matched to the same data item across builds; use the item's id. ## The correct lifecycle | Step | API | Your job | |---|---|---| | Swipe past the threshold or fling | `dismissThresholds` (default 0.4 of the extent), or a fling of at least 700 px/s clearly along the swipe axis | usually nothing | | Decide | `confirmDismiss: (direction) async => bool?` | ask the user or the server; `false` or `null` slides the item back | | Collapse | `resizeDuration` (300 ms), `onResize` | nothing; `null` duration skips the collapse and calls `onDismissed` at once | | Commit | `onDismissed(direction)` | remove the item from the data **synchronously** in `setState` (or your state library) | `confirmDismiss` is the right place for anything asynchronous. The row cannot be dragged again until its future resolves, and a failed server delete can simply return `false`. Removing the item optimistically in `onDismissed` and re-inserting it on **Undo** is the other safe pattern: the dismissed widget leaves the tree immediately, and undo creates a fresh one. ## Other parameters - **`direction`** — `DismissDirection.horizontal` by default; also `endToStart`, `startToEnd`, `vertical`, `up`, `down`, `none`. `endToStart` respects right-to-left layouts. - **`background`** and **`secondaryBackground`** — painted behind the sliding child; when both are given, the secondary one shows for `endToStart` and `up` swipes. - **`movementDuration`** — 200 ms for the slide. - **`behavior`** — `HitTestBehavior.opaque` by default, so the whole row is swipeable. ## Production checklist 1. The key is derived from the item's identity. 2. `onDismissed` removes the item in the same frame. 3. Asynchronous confirmation lives in `confirmDismiss`, which never uses a `BuildContext` after an `await` without checking `mounted`. 4. Undo re-inserts into the data rather than trying to "un-dismiss" the widget. 5. Swiping is not the only way to delete: a menu or long-press action keeps the feature reachable for users who cannot swipe.

  • How do you add an Undo action without triggering the assertion?
    Remove the item from the data in `onDismissed` immediately, remember it and its index, and show a snackbar whose Undo action re-inserts the item with `setState`. The dismissed widget leaves the tree at once; undo builds a fresh `Dismissible` for the restored item. Never delay the removal until the snackbar closes.
  • What does confirmDismiss returning null do?
    It is treated as `false`: the source awaits the callback and falls back to false with `?? false`. The child slides back to its original position, and neither `onResize` nor `onDismissed` runs, so a dialog dismissed by tapping outside it safely cancels the deletion.

saying these in an interview costs you the question

  • Dismissible removes the item from the list by itself.
  • ValueKey(index) is fine as a Dismissible key.
  • Delete from the list only when the undo snackbar closes.
  • confirmDismiss returning null counts as confirmed.
  • Run the server delete in onDismissed and remove the row after it returns.