Perché le transizioni di navigazione contano
Quando l'utente tocca la card di un prodotto e questa "vola" verso la schermata di dettaglio, il cervello percepisce continuità: non è comparsa una nuova pagina, è la stessa cosa che si è ingrandita. È un principio ben noto del motion design (Material lo chiama shared element transition): il movimento spiega la relazione tra due schermate e riduce il carico cognitivo.
Flutter offre tutti gli strumenti per farlo, ma sono sparsi tra widget del framework (Hero), API di routing (PageRouteBuilder, CustomTransitionPage) e il pacchetto ufficiale animations. In questa guida li mettiamo in fila, con i tranelli che si incontrano davvero in produzione.
Hero: le basi
Hero è il widget che implementa la shared element transition. La regola è semplice: due widget Hero con lo stesso tag, presenti su due route diverse, vengono collegati durante la transizione.
// Schermata lista
GestureDetector(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ProductDetailPage(product: product)),
),
child: Hero(
tag: 'product-${product.id}',
child: Image.network(product.imageUrl, width: 80, height: 80, fit: BoxFit.cover),
),
)
// Schermata dettaglio
Hero(
tag: 'product-${product.id}',
child: Image.network(product.imageUrl, height: 300, fit: BoxFit.cover),
)
Non serve altro: al push il framework calcola i rettangoli di partenza e di arrivo e anima il widget tra i due.
Cosa succede sotto il cofano
Capire il meccanismo evita il 90% dei bug:
- All'inizio della transizione il
HeroController(installato di default daMaterialApp) cerca gliHerocon lo stesso tag nelle due route. - I due
Herooriginali diventano invisibili (mantengono lo spazio, ma non disegnano nulla). - Viene creato un widget "in volo" inserito nell'
OverlaydelNavigator, che si anima da unRectall'altro seguendo una curva. - A fine animazione l'overlay viene rimosso e l'
Herodi destinazione torna visibile.
Da qui derivano due conseguenze pratiche: il widget in volo è fuori dall'albero originale (non eredita padding, Theme locali o Material circostante), e i tag devono essere unici per route.
I quattro errori più frequenti
1. Tag duplicati nella stessa schermata
Se due Hero con lo stesso tag sono visibili contemporaneamente, Flutter lancia un'asserzione. Succede spessissimo con le liste: usare tag: 'product' fisso è sbagliato, serve un identificativo per elemento ('product-${item.id}'). Attenzione anche a TabBarView e IndexedStack, dove più pagine restano montate insieme.
2. Testo che "balla" durante il volo
Un Text che passa da 14 a 28 px viene ridimensionato con uno scaling del RenderBox, non con un'interpolazione tipografica: durante il volo può apparire sfocato o mostrare un overflow giallo/nero, perché i vincoli intermedi sono diversi da quelli finali. La soluzione è avvolgere il contenuto:
Hero(
tag: 'title-${product.id}',
child: Material(
type: MaterialType.transparency,
child: DefaultTextStyle(
style: Theme.of(context).textTheme.titleLarge!,
child: SizedBox(
width: 240,
child: Text(product.name, maxLines: 1, overflow: TextOverflow.ellipsis),
),
),
),
)
Material(type: MaterialType.transparency) è quasi obbligatorio quando l'Hero contiene testo, InkWell o Chip: nell'overlay non c'è nessun Material antenato e il rendering cambierebbe.
3. Forme diverse tra partenza e arrivo
Un avatar circolare che diventa un'immagine rettangolare a schermo intero, con un semplice Hero, "salta" da una forma all'altra. Per animare anche il borderRadius serve il flightShuttleBuilder, che permette di sostituire il widget in volo:
Hero(
tag: 'avatar-${user.id}',
flightShuttleBuilder: (context, animation, direction, fromContext, toContext) {
final radius = Tween<double>(begin: 40, end: 0).animate(
CurvedAnimation(parent: animation, curve: Curves.easeInOut),
);
return AnimatedBuilder(
animation: radius,
builder: (context, _) => ClipRRect(
borderRadius: BorderRadius.circular(radius.value),
child: Image.network(user.avatarUrl, fit: BoxFit.cover),
),
);
},
child: ClipOval(child: Image.network(user.avatarUrl, fit: BoxFit.cover)),
)
Nota: quando direction è HeroFlightDirection.pop, l'animation va comunque da 0 a 1 nel senso della route di destinazione, quindi in genere conviene invertire i Tween in base alla direzione se l'effetto non è simmetrico.
4. Buco visibile nella schermata di partenza
Durante il volo l'Hero sorgente non disegna nulla e resta un vuoto. Con placeholderBuilder si può mostrare uno sfondo neutro:
Hero(
tag: 'product-${product.id}',
placeholderBuilder: (context, size, child) => Container(
width: size.width,
height: size.height,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
),
child: ...,
)
Hero e gesture su iOS
Su iOS lo swipe-to-go-back di CupertinoPageRoute non attiva l'Hero per impostazione predefinita. Per farlo, imposta su entrambi gli Hero:
Hero(
tag: 'product-${product.id}',
transitionOnUserGestures: true,
child: ...,
)
Transizioni di pagina personalizzate con PageRouteBuilder
Quando serve controllare l'intera animazione della route (non solo un elemento condiviso), si usa PageRouteBuilder:
Route<T> slideUpRoute<T>(Widget page) {
return PageRouteBuilder<T>(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final curved = CurvedAnimation(parent: animation, curve: Curves.easeOutCubic);
return SlideTransition(
position: Tween(begin: const Offset(0, 0.15), end: Offset.zero).animate(curved),
child: FadeTransition(opacity: curved, child: child),
);
},
);
}
Due dettagli spesso ignorati:
secondaryAnimationdescrive l'uscita della pagina quando ne arriva un'altra sopra. Usarla permette effetti coordinati (per esempio la pagina che scivola leggermente indietro invece di restare ferma).opaque: falseè necessario se vuoi che la route sottostante resti visibile (dialog, bottom sheet full-screen); ha però un costo, perché la pagina precedente continua a essere disegnata.
Transizioni con go_router
Con il routing dichiarativo si usa CustomTransitionPage, che espone le stesse animazioni:
GoRoute(
path: '/product/:id',
pageBuilder: (context, state) => CustomTransitionPage<void>(
key: state.pageKey,
child: ProductDetailPage(id: state.pathParameters['id']!),
transitionDuration: const Duration(milliseconds: 320),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: CurvedAnimation(parent: animation, curve: Curves.easeIn),
child: child,
);
},
),
)
Gli Hero funzionano anche qui, purché le due pagine appartengano allo stesso Navigator: se usi una ShellRoute con navigator annidati, un Hero non può volare da una pagina della shell a una pagina pushata sul navigator root.
Per disattivare completamente l'animazione (utile su schermate di splash o redirect) esiste NoTransitionPage.
Impostare le transizioni a livello di tema
Se vuoi uno stile uniforme senza toccare ogni route, configura pageTransitionsTheme:
MaterialApp(
theme: ThemeData(
pageTransitionsTheme: const PageTransitionsTheme(
builders: {
TargetPlatform.android: PredictiveBackPageTransitionsBuilder(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
TargetPlatform.macOS: CupertinoPageTransitionsBuilder(),
TargetPlatform.windows: FadeUpwardsPageTransitionsBuilder(),
},
),
),
)
PredictiveBackPageTransitionsBuilder abilita il predictive back di Android 14+, che mostra un'anteprima della schermata precedente mentre l'utente trascina dal bordo. Ricorda di dichiarare android:enableOnBackInvokedCallback="true" nel manifest per attivarlo lato sistema.
Il pacchetto animations: transizioni Material pronte all'uso
Il package ufficiale animations implementa i pattern di Material Motion e risolve casi che con Hero sarebbero laboriosi.
OpenContainer: card che si espande in pagina
OpenContainer<bool>(
transitionType: ContainerTransitionType.fadeThrough,
transitionDuration: const Duration(milliseconds: 400),
closedElevation: 1,
closedShape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
closedBuilder: (context, openContainer) => ProductCard(
product: product,
onTap: openContainer,
),
openBuilder: (context, closeContainer) => ProductDetailPage(product: product),
)
A differenza di Hero, qui l'intero contenitore si espande: niente tag da gestire, niente rischio di duplicati, e il contenuto interno viene sfumato automaticamente. È la scelta migliore per liste di card che aprono un dettaglio.
SharedAxis e FadeThrough per la navigazione tra sezioni
Per cambiare tab o step di un wizard, dove non c'è un elemento condiviso, usa PageTransitionSwitcher:
PageTransitionSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation, secondaryAnimation) =>
SharedAxisTransition(
animation: animation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.horizontal,
child: child,
),
child: _pages[_currentIndex],
)
Regola pratica: shared axis per passaggi con relazione spaziale o sequenziale (step 1 → step 2), fade through per sezioni non correlate (tab della bottom bar), container transform per aprire un elemento.
Attenzione: assegna una Key diversa a ogni child, altrimenti lo switcher non rileva il cambio di pagina.
Accessibilità e performance
Alcuni utenti abilitano la riduzione delle animazioni a livello di sistema. Flutter espone questa preferenza e va rispettata:
final reduceMotion = MediaQuery.disableAnimationsOf(context);
return reduceMotion
? child
: SharedAxisTransition(/* ... */, child: child);
Sul fronte performance:
- Non annidare Hero dentro altri Hero e non metterne uno dentro un
ListVieworizzontale che scorre durante la transizione: i rettangoli calcolati diventano inconsistenti. - Le immagini di rete dentro un Hero dovrebbero essere già in cache (per esempio con
cached_network_imageoprecacheImage), altrimenti l'elemento in volo appare vuoto e poi "scatta". - Verifica le transizioni con DevTools → Performance in profile mode: una transizione che fa scendere sotto i 60/120 fps è quasi sempre causata da rebuild pesanti nel
pageBuilder, non dall'animazione in sé. Costruisci la pagina di destinazione con contenuti leggeri e carica i dati dopo il primo frame. - Durate ragionevoli: 200–300 ms per transizioni semplici, fino a 400 ms per un container transform. Oltre i 500 ms l'app sembra lenta.
Checklist finale
- Tag Hero univoci e derivati dall'ID del dato, mai stringhe fisse.
Material(type: MaterialType.transparency)quando l'Hero contiene testo o Ink.flightShuttleBuilderper animare forme, bordi o stili diversi tra le due route.transitionOnUserGestures: trueper lo swipe-back iOS.CustomTransitionPageconstate.pageKeyin go_router;NoTransitionPagedove l'animazione non serve.OpenContaineral posto di Hero quando è l'intera card ad aprirsi.- Rispetta
disableAnimationsOfe testa in profile mode su un dispositivo reale di fascia bassa.
Le transizioni non sono decorazione: sono il modo in cui l'interfaccia racconta la propria struttura. Con questi strumenti si ottiene un risultato nativo senza scrivere una riga di codice di piattaforma.