[{"data":1,"prerenderedAt":27},["ShallowReactive",2],{"articolo-transizioni-di-navigazione-in-flutter-hero-pageroutebuilder-e-shared-element":3,"comments-article-transizioni-di-navigazione-in-flutter-hero-pageroutebuilder-e-shared-element":26},{"id":4,"title":5,"slug":6,"excerpt":7,"body":8,"cover_image":9,"cover_remote_url":10,"cover_credit":11,"video_url":15,"status":16,"published_at":17,"meta_title":18,"meta_description":19,"category":20,"author":24},89,"Transizioni di navigazione in Flutter: Hero, PageRouteBuilder e shared element","transizioni-di-navigazione-in-flutter-hero-pageroutebuilder-e-shared-element","Come rendere fluida la navigazione tra schermate: Hero animation, flightShuttleBuilder, transizioni personalizzate con PageRouteBuilder e go_router, e il pacchetto animations con OpenContainer e SharedAxis.","## Perché le transizioni di navigazione contano\n\nQuando 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.\n\nFlutter 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.\n\n## Hero: le basi\n\n`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.\n\n```dart\n\u002F\u002F Schermata lista\nGestureDetector(\n  onTap: () => Navigator.of(context).push(\n    MaterialPageRoute(builder: (_) => ProductDetailPage(product: product)),\n  ),\n  child: Hero(\n    tag: 'product-${product.id}',\n    child: Image.network(product.imageUrl, width: 80, height: 80, fit: BoxFit.cover),\n  ),\n)\n\n\u002F\u002F Schermata dettaglio\nHero(\n  tag: 'product-${product.id}',\n  child: Image.network(product.imageUrl, height: 300, fit: BoxFit.cover),\n)\n```\n\nNon serve altro: al push il framework calcola i rettangoli di partenza e di arrivo e anima il widget tra i due.\n\n### Cosa succede sotto il cofano\n\nCapire il meccanismo evita il 90% dei bug:\n\n1. All'inizio della transizione il `HeroController` (installato di default da `MaterialApp`) cerca gli `Hero` con lo stesso tag nelle due route.\n2. I due `Hero` originali diventano **invisibili** (mantengono lo spazio, ma non disegnano nulla).\n3. Viene creato un widget \"in volo\" inserito nell'`Overlay` del `Navigator`, che si anima da un `Rect` all'altro seguendo una curva.\n4. A fine animazione l'overlay viene rimosso e l'`Hero` di destinazione torna visibile.\n\nDa 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**.\n\n## I quattro errori più frequenti\n\n### 1. Tag duplicati nella stessa schermata\n\nSe 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.\n\n### 2. Testo che \"balla\" durante il volo\n\nUn `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\u002Fnero, perché i vincoli intermedi sono diversi da quelli finali. La soluzione è avvolgere il contenuto:\n\n```dart\nHero(\n  tag: 'title-${product.id}',\n  child: Material(\n    type: MaterialType.transparency,\n    child: DefaultTextStyle(\n      style: Theme.of(context).textTheme.titleLarge!,\n      child: SizedBox(\n        width: 240,\n        child: Text(product.name, maxLines: 1, overflow: TextOverflow.ellipsis),\n      ),\n    ),\n  ),\n)\n```\n\n`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.\n\n### 3. Forme diverse tra partenza e arrivo\n\nUn 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:\n\n```dart\nHero(\n  tag: 'avatar-${user.id}',\n  flightShuttleBuilder: (context, animation, direction, fromContext, toContext) {\n    final radius = Tween\u003Cdouble>(begin: 40, end: 0).animate(\n      CurvedAnimation(parent: animation, curve: Curves.easeInOut),\n    );\n    return AnimatedBuilder(\n      animation: radius,\n      builder: (context, _) => ClipRRect(\n        borderRadius: BorderRadius.circular(radius.value),\n        child: Image.network(user.avatarUrl, fit: BoxFit.cover),\n      ),\n    );\n  },\n  child: ClipOval(child: Image.network(user.avatarUrl, fit: BoxFit.cover)),\n)\n```\n\nNota: 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.\n\n### 4. Buco visibile nella schermata di partenza\n\nDurante il volo l'Hero sorgente non disegna nulla e resta un vuoto. Con `placeholderBuilder` si può mostrare uno sfondo neutro:\n\n```dart\nHero(\n  tag: 'product-${product.id}',\n  placeholderBuilder: (context, size, child) => Container(\n    width: size.width,\n    height: size.height,\n    color: Theme.of(context).colorScheme.surfaceContainerHighest,\n  ),\n  child: ...,\n)\n```\n\n## Hero e gesture su iOS\n\nSu iOS lo swipe-to-go-back di `CupertinoPageRoute` non attiva l'Hero per impostazione predefinita. Per farlo, imposta su **entrambi** gli Hero:\n\n```dart\nHero(\n  tag: 'product-${product.id}',\n  transitionOnUserGestures: true,\n  child: ...,\n)\n```\n\n## Transizioni di pagina personalizzate con PageRouteBuilder\n\nQuando serve controllare l'intera animazione della route (non solo un elemento condiviso), si usa `PageRouteBuilder`:\n\n```dart\nRoute\u003CT> slideUpRoute\u003CT>(Widget page) {\n  return PageRouteBuilder\u003CT>(\n    pageBuilder: (context, animation, secondaryAnimation) => page,\n    transitionDuration: const Duration(milliseconds: 300),\n    reverseTransitionDuration: const Duration(milliseconds: 250),\n    transitionsBuilder: (context, animation, secondaryAnimation, child) {\n      final curved = CurvedAnimation(parent: animation, curve: Curves.easeOutCubic);\n      return SlideTransition(\n        position: Tween(begin: const Offset(0, 0.15), end: Offset.zero).animate(curved),\n        child: FadeTransition(opacity: curved, child: child),\n      );\n    },\n  );\n}\n```\n\nDue dettagli spesso ignorati:\n\n- `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).\n- `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.\n\n## Transizioni con go_router\n\nCon il routing dichiarativo si usa `CustomTransitionPage`, che espone le stesse animazioni:\n\n```dart\nGoRoute(\n  path: '\u002Fproduct\u002F:id',\n  pageBuilder: (context, state) => CustomTransitionPage\u003Cvoid>(\n    key: state.pageKey,\n    child: ProductDetailPage(id: state.pathParameters['id']!),\n    transitionDuration: const Duration(milliseconds: 320),\n    transitionsBuilder: (context, animation, secondaryAnimation, child) {\n      return FadeTransition(\n        opacity: CurvedAnimation(parent: animation, curve: Curves.easeIn),\n        child: child,\n      );\n    },\n  ),\n)\n```\n\nGli `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.\n\nPer disattivare completamente l'animazione (utile su schermate di splash o redirect) esiste `NoTransitionPage`.\n\n## Impostare le transizioni a livello di tema\n\nSe vuoi uno stile uniforme senza toccare ogni route, configura `pageTransitionsTheme`:\n\n```dart\nMaterialApp(\n  theme: ThemeData(\n    pageTransitionsTheme: const PageTransitionsTheme(\n      builders: {\n        TargetPlatform.android: PredictiveBackPageTransitionsBuilder(),\n        TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),\n        TargetPlatform.macOS: CupertinoPageTransitionsBuilder(),\n        TargetPlatform.windows: FadeUpwardsPageTransitionsBuilder(),\n      },\n    ),\n  ),\n)\n```\n\n`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.\n\n## Il pacchetto animations: transizioni Material pronte all'uso\n\nIl package ufficiale [`animations`](https:\u002F\u002Fpub.dev\u002Fpackages\u002Fanimations) implementa i pattern di Material Motion e risolve casi che con `Hero` sarebbero laboriosi.\n\n### OpenContainer: card che si espande in pagina\n\n```dart\nOpenContainer\u003Cbool>(\n  transitionType: ContainerTransitionType.fadeThrough,\n  transitionDuration: const Duration(milliseconds: 400),\n  closedElevation: 1,\n  closedShape: const RoundedRectangleBorder(\n    borderRadius: BorderRadius.all(Radius.circular(16)),\n  ),\n  closedBuilder: (context, openContainer) => ProductCard(\n    product: product,\n    onTap: openContainer,\n  ),\n  openBuilder: (context, closeContainer) => ProductDetailPage(product: product),\n)\n```\n\nA 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.\n\n### SharedAxis e FadeThrough per la navigazione tra sezioni\n\nPer cambiare tab o step di un wizard, dove non c'è un elemento condiviso, usa `PageTransitionSwitcher`:\n\n```dart\nPageTransitionSwitcher(\n  duration: const Duration(milliseconds: 300),\n  transitionBuilder: (child, animation, secondaryAnimation) =>\n      SharedAxisTransition(\n        animation: animation,\n        secondaryAnimation: secondaryAnimation,\n        transitionType: SharedAxisTransitionType.horizontal,\n        child: child,\n      ),\n  child: _pages[_currentIndex],\n)\n```\n\nRegola 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.\n\nAttenzione: assegna una `Key` diversa a ogni `child`, altrimenti lo switcher non rileva il cambio di pagina.\n\n## Accessibilità e performance\n\nAlcuni utenti abilitano la riduzione delle animazioni a livello di sistema. Flutter espone questa preferenza e va rispettata:\n\n```dart\nfinal reduceMotion = MediaQuery.disableAnimationsOf(context);\n\nreturn reduceMotion\n    ? child\n    : SharedAxisTransition(\u002F* ... *\u002F, child: child);\n```\n\nSul fronte performance:\n\n- **Non annidare Hero** dentro altri Hero e non metterne uno dentro un `ListView` orizzontale che scorre durante la transizione: i rettangoli calcolati diventano inconsistenti.\n- 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\".\n- Verifica le transizioni con **DevTools → Performance** in profile mode: una transizione che fa scendere sotto i 60\u002F120 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.\n- Durate ragionevoli: 200–300 ms per transizioni semplici, fino a 400 ms per un container transform. Oltre i 500 ms l'app sembra lenta.\n\n## Checklist finale\n\n- Tag Hero univoci e derivati dall'ID del dato, mai stringhe fisse.\n- `Material(type: MaterialType.transparency)` quando l'Hero contiene testo o Ink.\n- `flightShuttleBuilder` per animare forme, bordi o stili diversi tra le due route.\n- `transitionOnUserGestures: true` per lo swipe-back iOS.\n- `CustomTransitionPage` con `state.pageKey` in go_router; `NoTransitionPage` dove l'animazione non serve.\n- `OpenContainer` al posto di Hero quando è l'intera card ad aprirsi.\n- Rispetta `disableAnimationsOf` e testa in profile mode su un dispositivo reale di fascia bassa.\n\nLe 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.","https:\u002F\u002Fflutter.it\u002Fstorage\u002Farticles\u002F84e4aaa4-3485-49f0-85e0-54a3e9d66ff8.jpg","https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1781324140346-f982ded7ba1d?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w5NzA2NTJ8MHwxfHJhbmRvbXx8fHx8fHx8fDE3ODg2NjcyOTF8&ixlib=rb-4.1.0&q=80&w=1080",{"name":12,"author_url":13,"photo_url":14},"Brecht Corbeel","https:\u002F\u002Funsplash.com\u002F@brechtcorbeel","https:\u002F\u002Funsplash.com\u002Fphotos\u002Fglowing-purple-micron-logo-in-a-futuristic-blue-3d-environment-hdWX02b5HhA",null,"published","2026-09-06T04:01:31+00:00","Hero e transizioni di pagina in Flutter: guida pratica","Guida alle transizioni di navigazione in Flutter: Hero animation, flightShuttleBuilder, PageRouteBuilder, go_router e il pacchetto animations con OpenContainer.",{"id":21,"name":22,"slug":23},1,"Guide","guide",{"id":21,"name":25},"Flutter Bot",[],1789120580137]