skip to content

In Flutter, how does AnimatedList animate rows in and out, and why must insertItem and removeItem be paired with changes to your data?

level: middleimportance: should knowfreq 38%

answer

  1. it keeps its own item count
  2. GlobalKey<AnimatedListState>
  3. itemBuilder receives an Animation<double>
  4. removeItem needs a removed-item builder
  5. 300 ms by default

basics

~20 s

AnimatedList keeps its own item count, starting from initialItemCount, and changes it only through AnimatedListState.insertItem and removeItem, reached with a GlobalKey. Change your data in the same step, and give removeItem a builder that draws the removed item.

solid answer

~40 s

`AnimatedList` does not diff your data. It holds its own count, set once from `initialItemCount`, and changes it only when you call `insertItem` or `removeItem` on its `AnimatedListState`, usually through a `GlobalKey<AnimatedListState>` or `AnimatedList.of(context)`. Its `itemBuilder` gets `(context, index, animation)`, and I wrap the row in a transition such as `SizeTransition` or `FadeTransition` driven by that animation. To insert, I add to my list and call `insertItem(index)` together. To remove, I take the item out of my list first, keep it in a variable, and call `removeItem(index, (context, animation) => row(removed, animation))`: the index no longer maps to data, so the builder must draw the captured item while the animation runs backwards. Both default to 300 ms. `SliverAnimatedList` and `AnimatedGrid` work the same way.

code

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

class ToPackList extends StatefulWidget {
  const ToPackList({super.key, required this.initial});
  final List<String> initial;

  @override
  State<ToPackList> createState() => _ToPackListState();
}

class _ToPackListState extends State<ToPackList> {
  final _listKey = GlobalKey<AnimatedListState>();
  late final List<String> _items = [...widget.initial];

  Widget _row(String label, Animation<double> animation, {bool interactive = true}) {
    return SizeTransition(
      sizeFactor: animation,
      child: ListTile(
        title: Text(label),
        trailing: interactive
            ? IconButton(
                icon: const Icon(Icons.check),
                onPressed: () => _markPacked(label),
              )
            : null,
      ),
    );
  }

  void _add(String label) {
    _items.insert(0, label);
    _listKey.currentState!.insertItem(0);
  }

  void _markPacked(String label) {
    final index = _items.indexOf(label);
    if (index < 0) return;
    final removed = _items.removeAt(index);
    _listKey.currentState!.removeItem(
      index,
      (context, animation) => _row(removed, animation, interactive: false),
    );
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedList(
      key: _listKey,
      initialItemCount: _items.length,
      itemBuilder: (context, index, animation) => _row(_items[index], animation),
    );
  }
}

go deeper

for a junior

Recall the pieces: a GlobalKey<AnimatedListState>, insertItem and removeItem on that state, and an itemBuilder that receives an animation to wrap the row in a transition.

for a middle

Explain that the list keeps its own count, why data and list must change together, and why removeItem needs a builder for the item that is already gone from the data.

for a senior

Show how you keep them in sync under real updates: diffing a synced list into inserts and removes, non-interactive ghost rows, and index lookups by id at tap time.

for a principal

Weigh animated insert and remove against its cost in code and bugs, and decide where the diffing logic lives so every animated list in the app shares it.

## What AnimatedList is `AnimatedList` is a scrolling list that **animates rows as they are inserted or removed**. A plain `ListView.builder` just shows whatever the data holds on the next build, so a new row pops in and a deleted row vanishes. `AnimatedList` instead runs an `Animation<double>` for each inserted or removed row and hands it to your builder, which turns it into a size, fade or slide transition. The family: | Widget | State class | Use | |---|---|---| | `AnimatedList` | `AnimatedListState` | a standalone scrolling list | | `AnimatedList.separated` | `AnimatedListState` | with separators, plus a `removedSeparatorBuilder` | | `SliverAnimatedList` | `SliverAnimatedListState` | a list inside a `CustomScrollView` | | `AnimatedGrid` | `AnimatedGridState` | a grid, with a `gridDelegate` | | `SliverAnimatedGrid` | `SliverAnimatedGridState` | a grid inside a `CustomScrollView` | ## It keeps its own count The key fact is that the list **does not look at your data**. Its state stores an item count, initialised from `initialItemCount` in `initState` and never read again. After that, the count changes only when you call methods on the state object: - `insertItem(int index, {Duration duration})` adds one slot and animates it in; - `insertAllItems(int index, int length, {Duration duration})` adds several; - `removeItem(int index, AnimatedRemovedItemBuilder builder, {Duration duration})` removes one and animates it out; - `removeAllItems(AnimatedRemovedItemBuilder builder, {Duration duration})` removes everything. The default duration is 300 milliseconds. To call these methods you need the state: pass a `GlobalKey<AnimatedListState>` as the list's `key` and use `key.currentState!`, or call `AnimatedList.of(context)` from a widget below the list. Because the count lives in two places, your list and the widget's state, they must change together. If you add to your data but forget `insertItem`, the new row never appears; if you remove from your data but forget `removeItem`, the builder is asked for an index that no longer exists and throws a `RangeError`. ## Inserting 1. Add the item to your list at `index`. 2. Call `insertItem(index)`. 3. The list calls `itemBuilder(context, index, animation)` with an animation running from 0.0 to 1.0; rows that are not animating get an already-completed animation. ## Removing Removal is the part people get wrong. The method's own documentation says the item is removed **immediately**: from that moment its index is no longer passed to `itemBuilder`, yet the row stays on screen for the whole duration. So the list cannot use `itemBuilder` to draw it, and it asks you for a separate builder: 1. Remove the item from your list and keep it: `final removed = items.removeAt(index);`. 2. Call `removeItem(index, (context, animation) => buildRow(removed, animation))`. 3. The animation runs from 1.0 back to 0.0, so the same `SizeTransition` shrinks the row away. The row drawn by that builder is a ghost of the removed item. It should not be interactive, because any index it captured now points at a different item. ## Packing list example In a travel app's packing list, ticking 'packed' on 'umbrella' removes it from the to-pack section. The handler removes the umbrella from the data, then calls `removeItem` with a builder that draws the same row, without its checkbox, inside a `SizeTransition`. The rows below slide up smoothly instead of jumping. Adding 'sunscreen' at the top is `items.insert(0, sunscreen)` followed by `insertItem(0)`. ## What AnimatedList is not - It is not a diffing list: a whole new data list requires working out the inserts and removes yourself. - It is not reorderable: `ReorderableListView` has no insert or remove animations, and `AnimatedList` has no drag-to-reorder. - Its row transitions are ordinary transition widgets driven by the given animation; implicit animations on a row's own properties are a separate tool. ## Choosing the transition The `animation` passed to the builders is a plain `Animation<double>` from 0.0 to 1.0, so any transition widget that takes one works: - `SizeTransition(sizeFactor: animation)` collapses or grows the row's height, so the rows below slide smoothly; it is the usual choice for lists; - `FadeTransition(opacity: animation)` fades the row but keeps its full height until the animation ends, so the rows below then jump; - combining a fade inside a size transition gives the common 'fade and collapse' look. Use the same transition in `itemBuilder` and in the removal builder, so an item leaves the way it arrived.

  • Why does the code above call insertItem without setState?
    `insertItem` and `removeItem` call `setState` inside the list's own state, which rebuilds the list and asks `itemBuilder` for the new row. The parent only needs its own `setState` if something else it builds depends on the data, such as a counter in the app bar.
  • How do you animate a whole new version of the list that arrives from a sync?
    Compare old and new by id, then apply removals from the highest index down with `removeItem`, and insertions from the lowest index up with `insertItem`, changing your data list at each step so both counts stay equal. `AnimatedList` will not do this diff for you, and changing `initialItemCount` later has no effect.

AnimatedList is like a stage manager who only knows how many dancers are on stage from your announcements: bring a dancer on without announcing it and they are never lit, send one off without announcing it and the manager calls for someone who is no longer there.

saying these in an interview costs you the question

  • AnimatedList animates changes automatically when the data list passed to it changes.
  • Changing initialItemCount later adds or removes rows with animation.
  • removeItem can reuse itemBuilder because the item is still in the data.
  • Call removeItem first and remove the item from the data when the animation ends.
  • AnimatedList also supports drag-to-reorder out of the box.