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:

  1. All'inizio della transizione il HeroController (installato di default da MaterialApp) cerca gli Hero con lo stesso tag nelle due route.
  2. I due Hero originali diventano invisibili (mantengono lo spazio, ma non disegnano nulla).
  3. Viene creato un widget "in volo" inserito nell'Overlay del Navigator, che si anima da un Rect all'altro seguendo una curva.
  4. A fine animazione l'overlay viene rimosso e l'Hero di 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:

  • secondaryAnimation descrive 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 ListView orizzontale 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_image o precacheImage), 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.
  • flightShuttleBuilder per animare forme, bordi o stili diversi tra le due route.
  • transitionOnUserGestures: true per lo swipe-back iOS.
  • CustomTransitionPage con state.pageKey in go_router; NoTransitionPage dove l'animazione non serve.
  • OpenContainer al posto di Hero quando è l'intera card ad aprirsi.
  • Rispetta disableAnimationsOf e 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.