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
_removeItemcatturaremovedprima di chiamareremoveItem: al momento dell'animazione l'indice non esiste più nella lista. - Non serve
setStateper inserimenti e rimozioni: ci pensaAnimatedListState. 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:
SliverReorderableList+ animazioni manuali sugli item (es.AnimatedSizeoAnimatedSwitcherall'interno di ciascuna riga).- Pacchetti di terze parti come
animated_list_plus, che offronoImplicitlyAnimatedReorderableListcon diff automatico. - Riordino senza drag: mostrare frecce su/giù e affidarsi a
AnimatedListcon 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 leduration. - 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
SnackBarcon "Annulla" che reinserisce l'elemento allo stesso indice: conAnimatedListè 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.