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?
answer
- widget is a view, the list is data
- remove synchronously in onDismissed
- stable ValueKey, never the index
- confirmDismiss: false or null returns
- threshold 0.4, resize 300 ms
basics
~20 sAfter 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 linesimport '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
Remember that onDismissed must remove the item from your list, and that every Dismissible needs a key tied to the item.
Explain the move and resize animations, why the assertion fires, the default threshold and fling rules, and the null-means-false rule of confirmDismiss.
Design delete flows with optimistic removal plus undo or confirmation via confirmDismiss, avoid context after await, and offer a non-swipe alternative.
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.