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 unAnimate, che internamente crea e gestisce unAnimationController.- Gli effetti concatenati sono paralleli di default: partono tutti insieme al tempo zero.
- I valori di
slideYsono frazioni dell'altezza del widget:0.3significa "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 unAnimationController. 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
CustomScrollViewconaddAutomaticKeepAlives. Evitarerepeat()su elementi fuori viewport. - Sfruttare il parametro
child. Gli effetti usano internamente un patternAnimatedBuilder: il sottoalbero passato comechildnon viene ricostruito a ogni frame, solo ridisegnato. Usareconstdove possibile amplifica il vantaggio. - Attenzione a blur e shimmer.
blur()e gli effetti conShaderMasksono 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 unRepaintBoundary.
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
PageRouteBuildereHero; - 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.