Liste animate in Flutter con AnimatedList e SliverAnimatedList

Foto di Roman su Unsplash

GuideIntermedio35 min Flutter 3.x

Liste animate in Flutter con AnimatedList e SliverAnimatedList

Quando aggiungi o rimuovi un elemento da una ListView, Flutter ridisegna la lista di colpo: niente transizioni, l'utente perde il filo di cosa è cambiato.

AnimatedList (e la sua controparte SliverAnimatedList) risolve il problema gestendo un conteggio interno di elementi che puoi far crescere o diminuire con animazione tramite insertItem e removeItem.

Il punto delicato — e il motivo per cui molti sbagliano — è che AnimatedList non osserva la tua lista di dati: devi tenere sincronizzati manualmente la sorgente dati e lo stato del widget. In questa guida costruiamo una piccola app di task in cui:

  • incapsuliamo la sincronizzazione in una classe riutilizzabile ListModel;
  • animiamo inserimenti singoli e multipli;
  • animiamo le rimozioni con undo tramite SnackBar;
  • portiamo tutto dentro un CustomScrollView con SliverAnimatedList e SliverAppBar.

Servono conoscenze di base su StatefulWidget, GlobalKey e animazioni implicite/esplicite.

  1. 1

    Modello dati e classe ListModel per sincronizzare dati e widget

    AnimatedList mantiene internamente solo il numero di elementi da mostrare. Se rimuovi un oggetto dalla tua List<Task> senza chiamare removeItem, il widget continuerà a chiedere un elemento che non esiste più → RangeError.

    La soluzione pulita è incapsulare la lista reale e la GlobalKey<AnimatedListState> in un'unica classe: ogni mutazione passa da lì e aggiorna entrambi.

    Crea lib/list_model.dart con il modello Task e la classe generica ListModel<E>.

    Nota il parametro removedItemBuilder: quando un elemento viene rimosso dai dati, il widget deve comunque essere disegnato per la durata dell'animazione di uscita, quindi serve un builder che sappia ricostruirlo a partire dall'oggetto già estratto.

    import 'package:flutter/material.dart';
    
    class Task {
      const Task({required this.id, required this.title});
    
      final int id;
      final String title;
    }
    
    typedef RemovedItemBuilder<E> = Widget Function(
      E item,
      BuildContext context,
      Animation<double> animation,
    );
    
    /// Tiene allineati la lista dei dati e lo stato di AnimatedList.
    class ListModel<E> {
      ListModel({
        required this.listKey,
        required this.removedItemBuilder,
        Iterable<E>? initialItems,
      }) : _items = List<E>.from(initialItems ?? <E>[]);
    
      final GlobalKey<AnimatedListState> listKey;
      final RemovedItemBuilder<E> removedItemBuilder;
      final List<E> _items;
    
      static const Duration _duration = Duration(milliseconds: 350);
    
      AnimatedListState get _animatedList => listKey.currentState!;
    
      int get length => _items.length;
      bool get isEmpty => _items.isEmpty;
      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, duration: _duration);
      }
    
      void insertAll(int index, List<E> items) {
        _items.insertAll(index, items);
        _animatedList.insertAllItems(index, items.length, duration: _duration);
      }
    
      E removeAt(int index) {
        final E removed = _items.removeAt(index);
        _animatedList.removeItem(
          index,
          (context, animation) => removedItemBuilder(removed, context, animation),
          duration: _duration,
        );
        return removed;
      }
    }

    Risultato atteso

    Hai una classe `ListModel` riutilizzabile: ogni inserimento/rimozione aggiorna sia i dati sia il widget, senza bisogno di `setState` sulla lista.

  2. 2

    Costruire la schermata con AnimatedList

    Ora creiamo lo StatefulWidget che ospita la lista. Punti chiave:

    • la GlobalKey<AnimatedListState> va creata una sola volta in initState (o come campo final), mai nel build;
    • initialItemCount deve coincidere con il numero di elementi iniziali del modello;
    • l'itemBuilder riceve un Animation<double> che va applicato al widget: tipicamente SizeTransition (per far "crescere" lo spazio) combinata con FadeTransition.

    Isoliamo la riga in un widget _TaskTile così da riutilizzarlo sia per gli elementi vivi sia per quelli in uscita.

    import 'package:flutter/material.dart';
    import 'list_model.dart';
    
    class TaskListPage extends StatefulWidget {
      const TaskListPage({super.key});
    
      @override
      State<TaskListPage> createState() => _TaskListPageState();
    }
    
    class _TaskListPageState extends State<TaskListPage> {
      final GlobalKey<AnimatedListState> _listKey = GlobalKey<AnimatedListState>();
      late final ListModel<Task> _tasks;
      int _nextId = 0;
    
      @override
      void initState() {
        super.initState();
        final initial = List<Task>.generate(
          3,
          (i) => Task(id: _nextId++, title: 'Task iniziale ${i + 1}'),
        );
        _tasks = ListModel<Task>(
          listKey: _listKey,
          initialItems: initial,
          removedItemBuilder: _buildRemovedItem,
        );
      }
    
      Widget _buildRemovedItem(
        Task task,
        BuildContext context,
        Animation<double> animation,
      ) {
        return _TaskTile(task: task, animation: animation);
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: const Text('AnimatedList')),
          body: AnimatedList(
            key: _listKey,
            initialItemCount: _tasks.length,
            padding: const EdgeInsets.symmetric(vertical: 8),
            itemBuilder: (context, index, animation) {
              return _TaskTile(
                task: _tasks[index],
                animation: animation,
              );
            },
          ),
        );
      }
    }
    
    class _TaskTile extends StatelessWidget {
      const _TaskTile({
        required this.task,
        required this.animation,
        this.onDelete,
      });
    
      final Task task;
      final Animation<double> animation;
      final VoidCallback? onDelete;
    
      @override
      Widget build(BuildContext context) {
        final curved = CurvedAnimation(
          parent: animation,
          curve: Curves.easeOutCubic,
        );
        return SizeTransition(
          sizeFactor: curved,
          child: FadeTransition(
            opacity: curved,
            child: Card(
              margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
              child: ListTile(
                leading: CircleAvatar(child: Text('${task.id}')),
                title: Text(task.title),
                trailing: onDelete == null
                    ? null
                    : IconButton(
                        icon: const Icon(Icons.delete_outline),
                        onPressed: onDelete,
                      ),
              ),
            ),
          ),
        );
      }
    }

    Risultato atteso

    L'app mostra tre card statiche. Non ci sono ancora animazioni visibili perché nessun elemento viene aggiunto o rimosso.

  3. 3

    Inserire elementi con animazione (singoli e multipli)

    Aggiungiamo un FloatingActionButton che inserisce un task in cima alla lista e un pulsante in AppBar che ne inserisce tre in un colpo solo con insertAllItems.

    Dettaglio importante: non serve setState per aggiungere elementi, perché insertItem notifica direttamente l'AnimatedListState. Usa setState solo se cambia altro stato della pagina (es. un contatore mostrato nell'AppBar).

    Se vuoi che la lista scorra fino al nuovo elemento, collega uno ScrollController e chiama animateTo dopo l'inserimento.

      void _addTask() {
        final task = Task(id: _nextId, title: 'Nuovo task $_nextId');
        _nextId++;
        _tasks.insert(0, task); // inserisce in testa con animazione
      }
    
      void _addBatch() {
        final batch = List<Task>.generate(
          3,
          (i) => Task(id: _nextId + i, title: 'Batch ${_nextId + i}'),
        );
        _nextId += batch.length;
        _tasks.insertAll(_tasks.length, batch); // in coda, tutti insieme
      }
    
      // Nel build dello Scaffold:
      // appBar: AppBar(
      //   title: const Text('AnimatedList'),
      //   actions: [
      //     IconButton(
      //       icon: const Icon(Icons.playlist_add),
      //       onPressed: _addBatch,
      //     ),
      //   ],
      // ),
      // floatingActionButton: FloatingActionButton(
      //   onPressed: _addTask,
      //   child: const Icon(Icons.add),
      // ),

    Risultato atteso

    Premendo il FAB una nuova card compare in testa espandendosi e dissolvendosi in entrata; il pulsante in AppBar aggiunge tre card in coda contemporaneamente.

  4. 4

    Rimuovere elementi con animazione e undo tramite SnackBar

    La rimozione è l'operazione più insidiosa: l'elemento deve sparire dai dati subito (altrimenti l'indice si disallinea) ma restare visibile fino a fine animazione. È esattamente ciò che fa ListModel.removeAt, grazie al removedItemBuilder.

    Due regole d'oro:

    1. calcola l'indice al momento del tap, con indexOf, non salvare indici in variabili di lungo periodo;
    2. nel widget in uscita passa onDelete: null, così l'utente non può premere due volte "elimina" sullo stesso elemento durante l'animazione.

    Aggiungiamo anche l'undo: ri-inseriamo l'elemento nella stessa posizione, ottenendo l'animazione inversa gratis.

      void _removeTask(Task task) {
        final index = _tasks.indexOf(task);
        if (index < 0) return; // già rimosso
        final removed = _tasks.removeAt(index);
    
        ScaffoldMessenger.of(context)
          ..hideCurrentSnackBar()
          ..showSnackBar(
            SnackBar(
              content: Text('"${removed.title}" eliminato'),
              action: SnackBarAction(
                label: 'ANNULLA',
                onPressed: () => _tasks.insert(
                  index.clamp(0, _tasks.length),
                  removed,
                ),
              ),
            ),
          );
      }
    
      // itemBuilder aggiornato:
      // itemBuilder: (context, index, animation) {
      //   final task = _tasks[index];
      //   return _TaskTile(
      //     task: task,
      //     animation: animation,
      //     onDelete: () => _removeTask(task),
      //   );
      // },

    Risultato atteso

    Toccando l'icona cestino la card collassa e svanisce; la SnackBar permette di ripristinarla nella stessa posizione con animazione di entrata.

  5. 5

    Portare l'animazione dentro un CustomScrollView con SliverAnimatedList

    Se la lista deve convivere con una SliverAppBar collassabile o con altri sliver, AnimatedList non basta: è un widget scrollabile a sé. Usa SliverAnimatedList, che ha la stessa API ma vive dentro un CustomScrollView.

    Le differenze pratiche sono due:

    • la chiave diventa GlobalKey<SliverAnimatedListState>;
    • il padding si applica con SliverPadding, non con il parametro padding.

    Per riusare il ListModel basta renderlo generico sul tipo di stato oppure, come qui, creare una variante che accetta una chiave sliver. In questo esempio duplichiamo solo le poche righe necessarie.

    class SliverTaskListPage extends StatefulWidget {
      const SliverTaskListPage({super.key});
    
      @override
      State<SliverTaskListPage> createState() => _SliverTaskListPageState();
    }
    
    class _SliverTaskListPageState extends State<SliverTaskListPage> {
      final _listKey = GlobalKey<SliverAnimatedListState>();
      final List<Task> _items = List<Task>.generate(
        5,
        (i) => Task(id: i, title: 'Task $i'),
      );
    
      void _remove(Task task) {
        final index = _items.indexOf(task);
        if (index < 0) return;
        final removed = _items.removeAt(index);
        _listKey.currentState!.removeItem(
          index,
          (context, animation) => _TaskTile(task: removed, animation: animation),
          duration: const Duration(milliseconds: 350),
        );
      }
    
      void _add() {
        final task = Task(id: _items.length, title: 'Task ${_items.length}');
        _items.insert(0, task);
        _listKey.currentState!.insertItem(
          0,
          duration: const Duration(milliseconds: 350),
        );
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          floatingActionButton: FloatingActionButton(
            onPressed: _add,
            child: const Icon(Icons.add),
          ),
          body: CustomScrollView(
            slivers: [
              const SliverAppBar.large(title: Text('Sliver animata')),
              SliverPadding(
                padding: const EdgeInsets.only(bottom: 80),
                sliver: SliverAnimatedList(
                  key: _listKey,
                  initialItemCount: _items.length,
                  itemBuilder: (context, index, animation) {
                    final task = _items[index];
                    return _TaskTile(
                      task: task,
                      animation: animation,
                      onDelete: () => _remove(task),
                    );
                  },
                ),
              ),
            ],
          ),
        );
      }
    }

    Risultato atteso

    La lista animata scorre insieme alla SliverAppBar che si collassa, mantenendo le stesse transizioni di entrata e uscita.

  6. 6

    Errori comuni, varianti e best practice

    Prima di portare AnimatedList in produzione, tieni a mente questi punti.

    Errori frequenti

    • Null check operator used on a null value su listKey.currentState: stai mutando la lista prima che il widget sia montato (es. nel initState o in un callback arrivato dopo il dispose). Proteggi con final state = listKey.currentState; if (state == null) return;.
    • RangeError (index): hai modificato la List sorgente senza passare da insertItem/removeItem. Rendi la lista privata e esponi solo i metodi del ListModel.
    • Due chiavi uguali: non riusare la stessa GlobalKey in due AnimatedList contemporaneamente (es. in un TabBarView che li costruisce entrambi).
    • Dismissible dentro AnimatedList: funziona solo se in onDismissed rimuovi l'elemento con removeItem passando un builder che restituisce SizedBox.shrink(), altrimenti vedrai la riga "tornare" per un istante.

    Varianti utili

    • AnimatedGrid / SliverAnimatedGrid: stessa logica per le griglie.
    • AnimatedList.separated (dalle versioni recenti di Flutter 3.x) aggiunge i separatori animati senza dover gestire indici pari/dispari.
    • Se preferisci un approccio dichiarativo in cui passi la lista e il diff viene calcolato per te, valuta pacchetti come animated_list_plus o usa AnimatedSwitcher per liste molto corte.

    Performance: mantieni gli item const dove possibile, evita di ricostruire l'intera pagina con setState a ogni mutazione e assegna una ValueKey stabile alle righe se contengono stato interno.

    Come esercizio finale, aggiungi un pulsante "Svuota" che rimuove tutti gli elementi uno alla volta con un piccolo ritardo, per ottenere un effetto a cascata.

      Future<void> _clearWithStagger() async {
        while (!_tasks.isEmpty) {
          _tasks.removeAt(_tasks.length - 1);
          await Future<void>.delayed(const Duration(milliseconds: 80));
          if (!mounted) return; // il widget potrebbe essere stato smontato
        }
      }
    
      // Rimozione istantanea di tutti gli elementi (senza animazione di uscita):
      // _listKey.currentState?.removeAllItems(
      //   (context, animation) => const SizedBox.shrink(),
      //   duration: Duration.zero,
      // );

    Risultato atteso

    Premendo "Svuota" le card spariscono una dopo l'altra con effetto a cascata, senza eccezioni anche se si lascia la pagina durante l'animazione.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!