A Flutter packing list built on AnimatedList sometimes deletes the wrong item or throws a RangeError when users tap quickly; what goes wrong and how do you fix it?
answer
- two counts that must agree
- the ghost row is still tappable
- captured index now names another item
- look up by id at tap time
- diff a synced list into calls
basics
~20 sThe index a row captured goes stale: the removed row stays on screen and tappable, so a second tap removes the next item, and data changed without matching insertItem or removeItem calls makes itemBuilder index past the end.
solid answer
~50 sTwo things drift. First, **indices**: a delete button whose closure captured `index` keeps it while the row animates out, but after `removeItem` that index belongs to the next item, so a second tap on the fading row deletes 'charger' instead of 'passport'. I make the removal builder draw a non-interactive copy, and I look the index up by id at tap time. Second, **counts**: `AnimatedList` keeps its own item count and changes it only through `insertItem` and `removeItem`, so if a sync or a state library replaces the data without those calls, the builder is asked for indices that no longer exist and throws a `RangeError`, or new rows never show. The fix is one code path that changes the data and calls the matching method in the same step, and for bulk updates a diff by id applied as removals from the end, then insertions.
code
dart · 37 lines// Inside the State of a packing list that shows _items in an AnimatedList.
void _markPacked(String id) {
final index = _items.indexWhere((item) => item.id == id); // looked up now
if (index < 0) return; // already gone: a second tap does nothing
final removed = _items.removeAt(index);
_listKey.currentState!.removeItem(
index,
(context, animation) => SizeTransition(
sizeFactor: animation,
child: IgnorePointer(child: ListTile(title: Text(removed.label))),
),
);
}
void _applySync(List<PackingItem> next) {
final nextIds = {for (final item in next) item.id};
for (var i = _items.length - 1; i >= 0; i--) {
if (!nextIds.contains(_items[i].id)) {
final removed = _items.removeAt(i);
_listKey.currentState!.removeItem(
i,
(context, animation) => SizeTransition(
sizeFactor: animation,
child: ListTile(title: Text(removed.label)),
),
);
}
}
final currentIds = {for (final item in _items) item.id};
for (var i = 0; i < next.length; i++) {
if (!currentIds.contains(next[i].id)) {
final at = i < _items.length ? i : _items.length;
_items.insert(at, next[i]);
_listKey.currentState!.insertItem(at);
}
}
}go deeper
Remember that AnimatedList only knows about changes made through insertItem and removeItem, so every data change needs the matching call.
Explain why the removed row still shows and why its captured index then points at another item, and how looking up by id at tap time fixes it.
Show how you keep data and list aligned under real traffic: one entry point, id-based diffs applied from the end, non-interactive ghost rows, and tests for fast taps.
Decide whether the product's animated lists share one diffing helper or adapter, so every screen gets the same invariants instead of each re-solving index drift.
## The symptom A travel app's packing list uses `AnimatedList` so that packed items slide away. In testing it works, but in the field two bugs appear: - tapping the check button twice quickly removes **two** items, the second being the one below; - after the app syncs the list from the server, the screen throws a `RangeError` from the item builder, or newly synced items never appear. Both come from the same design fact: `AnimatedList` keeps its **own item count** and its own mapping from index to row, separate from the app's data list. ## Bug 1: a stale index in a ghost row When `removeItem(index, builder)` runs, the item is removed from the list's count immediately, and its index now refers to the next item. The removed row, however, stays on screen for the animation (300 ms by default), drawn by the builder you passed. If that builder reuses the normal row, the row still has its buttons, and their closures still hold the old index. 1. The user taps the check button on 'passport' at index 2. 2. The handler removes index 2 from the data and calls `removeItem(2, ...)`. 3. 'passport' shrinks away, but its button is still visible and enabled. 4. The user taps it again; the closure calls the handler with index 2. 5. Index 2 now holds 'charger', which is removed as well. Fixes, best used together: - make the removal builder draw a **non-interactive** copy, with no buttons, or wrap it in `IgnorePointer`; - look the index up **at tap time** from a stable id, `items.indexWhere((i) => i.id == id)`, and return if it is not found; - disable a button for an item already being removed if the design keeps it visible. ## Bug 2: two counts that disagree `initialItemCount` is read once, when the list's state is created. After that the list's count changes only through `insertItem`, `insertAllItems`, `removeItem` and `removeAllItems`. Anything that changes the data without those calls breaks the agreement: | What changed | List count | Result | |---|---|---| | data shrank, no `removeItem` | too high | `itemBuilder` indexes past the end: `RangeError` | | data grew, no `insertItem` | too low | new items never appear | | data replaced by a sync | unchanged | either of the above, and the wrong rows animate | | `removeItem` called, data not changed | too low | the last item disappears from view | This is common when the data comes from a state object or a stream: the parent rebuilds with a new list, and nobody translates that into list calls. ## Keeping them in sync - **One entry point.** All changes go through methods that update the data and call the matching list method in the same synchronous step. - **Diff bulk updates.** When a new list arrives, compare by id. Apply removals from the highest index down, so earlier indices stay valid, then insertions from the lowest index up, changing the data at each step. - **Reset deliberately.** If a change is too big to animate, give the `AnimatedList` a new key so its state, and its count, start again from `initialItemCount`; this rebuilds without animation rather than pretending to animate. - **Test it.** A widget test that taps twice within the animation and one that swaps the data from outside catch both bugs. ## Reorder plus animated removal The same app often wants drag-to-reorder too. `ReorderableListView` has no insert or remove animation API, and `AnimatedList` has no reordering. A common approach is a reorderable list whose rows animate their own collapse and are removed from the data when that animation ends; whichever you choose, keep one owner of the order and the count. ## A quick diagnosis 1. Does the error come from `itemBuilder` with an index equal to the data's length? The list's count is ahead of the data: a removal without `removeItem`. 2. Do new items appear only after the screen is rebuilt from scratch? The count is behind: an insertion without `insertItem`. 3. Does the wrong item vanish after a fast double tap? A stale index in a ghost row. 4. Does it only happen after a sync or a push update? Some path changes the data outside the single entry point. Each answer points at one of the fixes above, and each deserves a regression test.
- Why apply removals from the highest index down?Each `removeItem` shifts every later index down by one. Walking from the end means an index you have not processed yet is never affected by a removal you just made, so the data list and the list's count stay aligned at every step.
- When is giving the AnimatedList a new key the right fix?When the change is too large to animate meaningfully, such as switching to a different trip's list. A new key creates a new state that reads `initialItemCount` from the current data, so counts agree again. It resets scroll position and animates nothing, so it is a reset, not a way to animate a sync.
saying these in an interview costs you the question
- A row being removed can no longer be tapped, so double deletes cannot happen.
- AnimatedList re-reads initialItemCount whenever its parent rebuilds.
- Capturing the index in the button's closure is safe because indices never change.
- Apply sync removals from index 0 upward in one pass.
- Calling setState after replacing the data is enough to keep AnimatedList in sync.