Quando un elemento compare o sparisce di colpo da una ListView, l'utente perde il filo: non capisce cosa è cambiato. Flutter mette a disposizione una famiglia di widget pensata proprio per questo — AnimatedList, AnimatedGrid, le varianti sliver e ReorderableListView — che animano inserimenti, rimozioni e riordino degli elementi.

In questa guida vediamo come usarli correttamente, quali sono le trappole più comuni (soprattutto la sincronizzazione con lo stato dell'app) e come integrarli in liste reali con drag & drop.

Perché AnimatedList è diverso da ListView

ListView.builder è dichiarativo: gli dai una lista e lui ricostruisce. AnimatedList invece è imperativo: mantiene internamente un contatore di elementi (initialItemCount) e va notificato esplicitamente di ogni inserimento o rimozione tramite insertItem() e removeItem().

Questa differenza è la fonte del 90% dei bug: se aggiungi un elemento alla lista dati ma non chiami insertItem, il widget va in disallineamento e ottieni un RangeError oppure elementi duplicati.

Il caso base

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

  @override
  State<TaskListPage> createState() => _TaskListPageState();
}

class _TaskListPageState extends State<TaskListPage> {
  final _listKey = GlobalKey<AnimatedListState>();
  final List<String> _items = ['Spesa', 'Palestra', 'Chiamare Luca'];

  void _addItem(String value) {
    _items.insert(0, value);
    // Notifica il widget: l'indice deve corrispondere a quello dei dati
    _listKey.currentState?.insertItem(0, duration: const Duration(milliseconds: 300));
  }

  void _removeItem(int index) {
    final removed = _items.removeAt(index);
    _listKey.currentState?.removeItem(
      index,
      (context, animation) => _buildItem(removed, animation, onRemove: null),
      duration: const Duration(milliseconds: 250),
    );
  }

  Widget _buildItem(String value, Animation<double> animation, {VoidCallback? onRemove}) {
    return SizeTransition(
      sizeFactor: animation,
      child: FadeTransition(
        opacity: animation,
        child: ListTile(
          title: Text(value),
          trailing: onRemove == null
              ? null
              : IconButton(icon: const Icon(Icons.delete_outline), onPressed: onRemove),
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: AnimatedList(
        key: _listKey,
        initialItemCount: _items.length,
        padding: const EdgeInsets.symmetric(vertical: 8),
        itemBuilder: (context, index, animation) => _buildItem(
          _items[index],
          animation,
          onRemove: () => _removeItem(index),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => _addItem('Nuovo task ${_items.length + 1}'),
        child: const Icon(Icons.add),
      ),
    );
  }
}

Punti chiave:

  • Il builder di rimozione riceve l'elemento già rimosso dai dati. Per questo _removeItem cattura removed prima di chiamare removeItem: al momento dell'animazione l'indice non esiste più nella lista.
  • Non serve setState per inserimenti e rimozioni: ci pensa AnimatedListState. Lo userai solo se altri pezzi di UI dipendono dalla lista (es. un contatore in AppBar).
  • L'Animation<double> va da 0 a 1 in inserimento e da 1 a 0 in rimozione: puoi combinarla con qualsiasi transizione (SizeTransition, SlideTransition, ScaleTransition).

Incapsulare la logica in un ListModel

Tenere sincronizzati dati e widget sparpagliando insertItem ovunque diventa ingestibile. Il pattern consigliato (usato anche negli esempi ufficiali) è incapsulare lista e chiave in un'unica classe.

class AnimatedListModel<E> {
  AnimatedListModel({
    required this.listKey,
    required this.removedItemBuilder,
    Iterable<E>? initialItems,
  }) : _items = List<E>.from(initialItems ?? <E>[]);

  final GlobalKey<AnimatedListState> listKey;
  final Widget Function(E item, BuildContext context, Animation<double> animation)
      removedItemBuilder;
  final List<E> _items;

  AnimatedListState? get _animatedList => listKey.currentState;

  int get length => _items.length;
  E operator [](int index) => _items[index];
  int indexOf(E item) => _items.indexOf(item);

  void insert(int index, E item) {
    _items.insert(index, item);
    _animatedList?.insertItem(index);
  }

  E removeAt(int index) {
    final removed = _items.removeAt(index);
    _animatedList?.removeItem(
      index,
      (context, animation) => removedItemBuilder(removed, context, animation),
    );
    return removed;
  }
}

Ora la UI lavora sempre sul modello (model.insert(...), model.removeAt(...)) e l'invariante "dati e widget allineati" è garantita in un unico punto.

Sincronizzare con uno state manager

Con Riverpod, Bloc o qualsiasi altra soluzione dichiarativa il problema si ribalta: ricevi una nuova lista completa e devi dedurre quali elementi sono stati aggiunti o rimossi. La strada più semplice è calcolare il diff nel didUpdateWidget di un widget dedicato:

@override
void didUpdateWidget(covariant AnimatedTaskList oldWidget) {
  super.didUpdateWidget(oldWidget);
  _sync(oldWidget.tasks, widget.tasks);
}

void _sync(List<Task> oldList, List<Task> newList) {
  // Rimozioni: si parte dal fondo per non invalidare gli indici
  for (var i = oldList.length - 1; i >= 0; i--) {
    if (!newList.any((t) => t.id == oldList[i].id)) {
      final removed = oldList[i];
      _listKey.currentState?.removeItem(
        i,
        (context, animation) => _buildItem(removed, animation),
      );
    }
  }
  // Inserimenti: in ordine crescente
  for (var i = 0; i < newList.length; i++) {
    if (!oldList.any((t) => t.id == newList[i].id)) {
      _listKey.currentState?.insertItem(i);
    }
  }
}

Due regole d'oro: rimuovi dal fondo verso l'inizio, altrimenti gli indici successivi slittano; e usa un identificatore stabile (id) per il confronto, non l'uguaglianza per riferimento. Se la lista può cambiare in modo massiccio (filtri, ordinamenti), valuta pacchetti come animated_list_plus, che calcolano il diff automaticamente.

SliverAnimatedList e AnimatedGrid

Dentro un CustomScrollView serve la versione sliver, con la stessa API ma GlobalKey<SliverAnimatedListState>:

CustomScrollView(
  slivers: [
    const SliverAppBar.large(title: Text('Task')),
    SliverAnimatedList(
      key: _sliverKey,
      initialItemCount: _items.length,
      itemBuilder: (context, index, animation) =>
          _buildItem(_items[index], animation),
    ),
  ],
)

Da Flutter 3.10 esistono anche AnimatedGrid e SliverAnimatedGrid, identici come API ma con un gridDelegate. Per le griglie la transizione più efficace è ScaleTransition combinata con FadeTransition: SizeTransition su una cella di griglia produce un effetto poco naturale.

AnimatedGrid(
  key: _gridKey,
  initialItemCount: _photos.length,
  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 3,
    mainAxisSpacing: 4,
    crossAxisSpacing: 4,
  ),
  itemBuilder: (context, index, animation) => ScaleTransition(
    scale: CurvedAnimation(parent: animation, curve: Curves.easeOutBack),
    child: FadeTransition(opacity: animation, child: PhotoTile(_photos[index])),
  ),
)

Nelle versioni recenti del framework è disponibile anche AnimatedList.separated, che accetta separatorBuilder e anima correttamente anche i separatori: se la tua versione di Flutter non lo espone, l'alternativa è includere il divider dentro l'item builder.

ReorderableListView: il riordino drag & drop

Il riordino è un capitolo a parte, gestito da ReorderableListView. Il requisito assoluto: ogni figlio deve avere una Key univoca e stabile, legata al dato e non all'indice.

ReorderableListView.builder(
  itemCount: _items.length,
  onReorder: (oldIndex, newIndex) {
    setState(() {
      // Correzione obbligatoria: spostando verso il basso,
      // newIndex tiene conto dell'elemento non ancora rimosso
      if (newIndex > oldIndex) newIndex -= 1;
      final item = _items.removeAt(oldIndex);
      _items.insert(newIndex, item);
    });
  },
  itemBuilder: (context, index) {
    final task = _items[index];
    return ListTile(
      key: ValueKey(task.id), // mai ValueKey(index)!
      title: Text(task.title),
      leading: const Icon(Icons.drag_indicator),
    );
  },
)

Se usi ValueKey(index) Flutter non riesce a seguire l'identità degli elementi e vedrai contenuti che "saltano" o stati interni (checkbox, TextField) che migrano sull'elemento sbagliato.

Handle personalizzati

Su mobile l'intera riga è trascinabile con long-press; su desktop e web compaiono handle a destra. Per controllare il comportamento disattiva gli handle di default e usa ReorderableDragStartListener:

ReorderableListView.builder(
  buildDefaultDragHandles: false,
  itemCount: _items.length,
  onReorder: _onReorder,
  itemBuilder: (context, index) => ListTile(
    key: ValueKey(_items[index].id),
    title: Text(_items[index].title),
    trailing: ReorderableDragStartListener(
      index: index,
      child: const Icon(Icons.drag_handle),
    ),
  ),
)

Usa ReorderableDelayedDragStartListener se vuoi il long-press invece del trascinamento immediato — utile quando la riga è anche cliccabile.

Personalizzare l'elemento trascinato

Il proxyDecorator controlla l'aspetto dell'elemento "in volo":

proxyDecorator: (child, index, animation) {
  return AnimatedBuilder(
    animation: animation,
    builder: (context, _) {
      final t = Curves.easeInOut.transform(animation.value);
      return Material(
        elevation: lerpDouble(0, 8, t)!,
        borderRadius: BorderRadius.circular(12),
        shadowColor: Theme.of(context).colorScheme.shadow,
        child: child,
      );
    },
  );
}

Versione sliver

Per combinare riordino e altri slivers (header, griglie, sezioni) esiste SliverReorderableList, con la stessa firma di onReorder e itemBuilder. È la scelta obbligata quando la lista riordinabile convive con una SliverAppBar collassabile.

Combinare animazione e riordino

ReorderableListView non anima inserimenti e rimozioni, mentre AnimatedList non supporta il drag & drop. Se ti servono entrambi hai tre strade:

  1. SliverReorderableList + animazioni manuali sugli item (es. AnimatedSize o AnimatedSwitcher all'interno di ciascuna riga).
  2. Pacchetti di terze parti come animated_list_plus, che offrono ImplicitlyAnimatedReorderableList con diff automatico.
  3. Riordino senza drag: mostrare frecce su/giù e affidarsi a AnimatedList con remove+insert. È la soluzione più accessibile, perché il drag & drop è spesso poco usabile con screen reader.

Accessibilità e dettagli finali

  • Le animazioni vanno rispettate anche quando l'utente ha attivato la riduzione del movimento: controlla MediaQuery.disableAnimationsOf(context) e azzera le duration.
  • Etichetta gli handle con Semantics(label: 'Riordina elemento') e prevedi sempre un'alternativa non trascinabile (menu "Sposta su / Sposta giù").
  • Mantieni le durate sotto i 300 ms: oltre, la lista sembra lenta. Per le rimozioni usa durate più brevi rispetto agli inserimenti.
  • Dopo una rimozione offri sempre uno SnackBar con "Annulla" che reinserisce l'elemento allo stesso indice: con AnimatedList è banale e migliora molto la percezione di controllo.

Conclusione

AnimatedList e compagni non sono sostituti di ListView: sono strumenti per i casi in cui la lista cambia sotto gli occhi dell'utente. La regola pratica è semplice: se la lista si popola una volta sola, resta su ListView.builder; se elementi entrano ed escono durante l'uso (task, carrello, notifiche, chat), l'animazione riduce il carico cognitivo.

Incapsula sempre la coppia dati + GlobalKey in un modello dedicato, usa chiavi stabili nel riordino e ricordati della correzione di newIndex: con queste tre accortezze eviti quasi tutti i bug tipici di queste API.