Perché un pacchetto per le animazioni

Flutter offre un sistema di animazione completo: widget impliciti (AnimatedContainer, AnimatedOpacity) per le transizioni semplici e AnimationController + Tween per il controllo totale. Il problema nasce quando servono sequenze di più effetti combinati: un elemento che compare in dissolvenza, scivola dal basso, pulsa leggermente e poi resta fermo. Con l'approccio esplicito significa uno StatefulWidget, un TickerProvider, più Animation con Interval calcolati a mano e decine di righe di codice per ogni elemento.

flutter_animate risolve esattamente questo: espone un'API dichiarativa e concatenabile che si applica a qualsiasi widget tramite l'extension method .animate(). È sviluppato da Grant Skinner (gskinner), è maturo, non ha dipendenze native e non sostituisce il sistema di animazione di Flutter: lo incapsula.

Installazione

dependencies:
  flutter_animate: ^4.5.2
import 'package:flutter_animate/flutter_animate.dart';

Il pacchetto esporta anche le estensioni numeriche per le durate, quindi 500.ms, 2.seconds, 1.5.minutes sono subito disponibili.

Il primo effetto

Text('Ciao Flutter')
    .animate()
    .fadeIn(duration: 600.ms)
    .slideY(begin: 0.3, end: 0);

Tre cose da notare:

  • .animate() avvolge il widget in un Animate, che internamente crea e gestisce un AnimationController.
  • Gli effetti concatenati sono paralleli di default: partono tutti insieme al tempo zero.
  • I valori di slideY sono frazioni dell'altezza del widget: 0.3 significa "parti 30% più in basso".

Se non si specifica nulla, l'animazione parte automaticamente al primo build (autoPlay: true).

Durata, delay e curve

Ogni effetto accetta delay, duration e curve. Se omessi, eredita i valori dell'effetto precedente o i default globali.

Card(child: ListTile(title: Text('Notifica')))
    .animate()
    .fadeIn(duration: 400.ms, curve: Curves.easeOut)
    .scaleXY(begin: 0.95, end: 1, curve: Curves.easeOutBack)
    .shimmer(delay: 400.ms, duration: 900.ms, color: Colors.white54);

Sequenze con then()

.then() sposta il punto di partenza dell'effetto successivo alla fine del precedente, con un eventuale delay aggiuntivo. È il modo più leggibile per costruire timeline.

Icon(Icons.check_circle, size: 72, color: Colors.green)
    .animate()
    .scaleXY(begin: 0, end: 1, duration: 350.ms, curve: Curves.easeOutBack)
    .then(delay: 150.ms)
    .shake(hz: 3, rotation: 0.02)
    .then()
    .fadeOut(duration: 400.ms);

Senza then() i tre effetti si sovrapporrebbero; con then() si ottiene: comparsa → piccola scossa → uscita.

Stagger: animare una lista di widget

L'extension .animate() esiste anche su List<Widget> e restituisce un AnimateList, che applica gli stessi effetti a tutti i figli sfalsandoli con interval.

Column(
  crossAxisAlignment: CrossAxisAlignment.start,
  children: [
    Text('Titolo', style: Theme.of(context).textTheme.headlineSmall),
    const SizedBox(height: 8),
    const Text('Sottotitolo descrittivo'),
    const SizedBox(height: 16),
    FilledButton(onPressed: () {}, child: const Text('Inizia')),
  ].animate(interval: 90.ms).fadeIn(duration: 350.ms).slideX(begin: -0.1),
)

Funziona bene per schermate di onboarding, form e dashboard. Attenzione: in una ListView.builder con migliaia di elementi, conviene applicare .animate() al singolo item costruito, non all'intera lista.

Animazioni guidate dallo stato con target

Finora abbiamo visto animazioni "una tantum". Per animazioni che reagiscono allo stato si usa il parametro target: cambiandone il valore, Animate interpola verso quella posizione della timeline (0 = inizio, 1 = fine) invece di saltare.

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

  @override
  State<LikeButton> createState() => _LikeButtonState();
}

class _LikeButtonState extends State<LikeButton> {
  bool _liked = false;

  @override
  Widget build(BuildContext context) {
    return IconButton(
      onPressed: () => setState(() => _liked = !_liked),
      icon: const Icon(Icons.favorite)
          .animate(target: _liked ? 1 : 0)
          .tint(color: Colors.red, duration: 200.ms)
          .scaleXY(end: 1.25, duration: 200.ms, curve: Curves.easeOut)
          .then()
          .scaleXY(end: 1 / 1.25, duration: 150.ms),
    );
  }
}

Quando _liked torna false, l'animazione viene riprodotta al contrario in modo fluido. È il pattern da usare per toggle, stati di selezione, badge attivi/inattivi.

Loop, shimmer e skeleton

Per le animazioni continue si interviene sul controller tramite la callback onPlay:

Container(
  height: 18,
  decoration: BoxDecoration(
    color: Colors.grey.shade300,
    borderRadius: BorderRadius.circular(4),
  ),
)
    .animate(onPlay: (controller) => controller.repeat())
    .shimmer(duration: 1200.ms, color: Colors.white);

Altri casi tipici: controller.repeat(reverse: true) per un respiro pulsante, oppure onComplete per concatenare logica applicativa al termine dell'animazione.

const Icon(Icons.notifications)
    .animate(onPlay: (c) => c.repeat(reverse: true))
    .scaleXY(end: 1.08, duration: 800.ms, curve: Curves.easeInOut);

Controllo manuale con un AnimationController esterno

Quando l'animazione deve essere innescata da un evento (un errore di validazione, l'arrivo di un messaggio) si passa un controller proprio e si disattiva l'autoplay. Animate adatta la durata del controller alla durata complessiva degli effetti.

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

  @override
  State<ShakeField> createState() => _ShakeFieldState();
}

class _ShakeFieldState extends State<ShakeField>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller =
      AnimationController(vsync: this);

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  void _onError() => _controller.forward(from: 0);

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        const TextField()
            .animate(controller: _controller, autoPlay: false)
            .shakeX(hz: 5, amount: 6, duration: 400.ms),
        TextButton(onPressed: _onError, child: const Text('Simula errore')),
      ],
    );
  }
}

Effetti custom

Quando gli effetti inclusi non bastano, .custom() espone direttamente il valore interpolato:

Text('Caricamento')
    .animate(onPlay: (c) => c.repeat())
    .custom(
      duration: 1500.ms,
      builder: (context, value, child) => Opacity(
        opacity: 0.4 + (0.6 * (1 - (value - 0.5).abs() * 2)),
        child: child,
      ),
    );

Per riutilizzare un effetto composto in tutta l'app conviene scrivere un'extension su AnimateManager, così funziona sia su Animate sia su AnimateList:

extension AppEffects<T> on AnimateManager<T> {
  /// Comparsa standard dell'app: fade + leggero ingrandimento.
  T appear({Duration? delay}) => fadeIn(
        delay: delay,
        duration: 300.ms,
        curve: Curves.easeOut,
      ).scaleXY(begin: 0.96, end: 1, curve: Curves.easeOutCubic);
}

// Uso
MyCard().animate().appear();
[cardA, cardB, cardC].animate(interval: 70.ms).appear();

In questo modo il motion design dell'applicazione resta centralizzato e coerente, esattamente come si fa con il ThemeData.

Configurazione globale e hot reload

Nel main() è utile impostare i default e abilitare il restart delle animazioni a ogni hot reload durante lo sviluppo:

void main() {
  Animate.defaultDuration = 350.ms;
  Animate.defaultCurve = Curves.easeOut;
  assert(() {
    Animate.restartOnHotReload = true;
    return true;
  }());
  runApp(const MyApp());
}

L'assert garantisce che restartOnHotReload resti attivo solo in debug: in release il blocco viene rimosso dal compilatore.

Accessibilità: rispettare il "reduce motion"

Su iOS e Android l'utente può chiedere di ridurre le animazioni. Ignorare questa impostazione è un problema di accessibilità concreto (vertigini, disturbi vestibolari). Flutter la espone tramite MediaQuery:

class Appear extends StatelessWidget {
  const Appear({super.key, required this.child});

  final Widget child;

  @override
  Widget build(BuildContext context) {
    final reduceMotion = MediaQuery.disableAnimationsOf(context);
    if (reduceMotion) return child;
    return child.animate().fadeIn(duration: 300.ms).slideY(begin: 0.15);
  }
}

Una variante più morbida consiste nel mantenere solo i fade (percettivamente innocui) ed eliminare movimenti e scale.

Performance: cosa tenere d'occhio

  • Ogni .animate() crea un AnimationController. Decine di controller contemporanei in una lista scrollabile costano; animare solo gli elementi effettivamente visibili e preferire animazioni brevi e non ripetute.
  • Le animazioni in loop non si fermano da sole quando il widget esce dallo schermo in un CustomScrollView con addAutomaticKeepAlives. Evitare repeat() su elementi fuori viewport.
  • Sfruttare il parametro child. Gli effetti usano internamente un pattern AnimatedBuilder: il sottoalbero passato come child non viene ricostruito a ogni frame, solo ridisegnato. Usare const dove possibile amplifica il vantaggio.
  • Attenzione a blur e shimmer. blur() e gli effetti con ShaderMask sono i più costosi: su device di fascia bassa vanno misurati con DevTools (timeline raster).
  • Animare le foglie, non i rami. Applicare .animate() il più vicino possibile al widget che cambia riduce l'area di repaint; in alcuni casi conviene aggiungere un RepaintBoundary.

Quando non usarlo

flutter_animate è ideale per micro-interazioni, comparse, transizioni di stato e feedback visivi. Non è la scelta giusta quando serve:

  • un'animazione gesture-driven con controllo frame per frame (drag interattivo): meglio AnimationController + GestureDetector;
  • una transizione di route complessa: resta l'ambito di PageRouteBuilder e Hero;
  • grafica vettoriale animata da designer: lì ha più senso Rive o Lottie.

Conclusione

flutter_animate non introduce un nuovo motore di animazione: aggiunge un livello dichiarativo sopra quello esistente, eliminando il boilerplate che scoraggia dal curare i dettagli di movimento. Il risultato pratico è che diventa economico aggiungere micro-interazioni coerenti in tutta l'app, soprattutto se si centralizzano gli effetti in extension condivise. Come sempre, la regola d'oro resta la stessa: animazioni brevi (200–400 ms), con una funzione comunicativa chiara e disattivabili quando l'utente lo chiede.