Transizioni fluide tra schermate in Flutter con il widget Hero

Foto di Liana S su Unsplash

GuidePrincipiante30 min Flutter 3.x

Transizioni fluide tra schermate in Flutter con il widget Hero

Quando l'utente tocca l'immagine di un prodotto in una lista e si apre la schermata di dettaglio, un'app curata non "salta" bruscamente da una pagina all'altra: l'immagine vola dalla posizione iniziale a quella finale, guidando l'occhio e rendendo la navigazione naturale.

In Flutter questo effetto si ottiene con il widget Hero, ed è sorprendentemente semplice: non serve un AnimationController, non servono Tween. Basta avvolgere lo stesso elemento nelle due schermate con un Hero che condivide lo stesso tag, e il framework si occupa di calcolare posizione, dimensione e interpolazione durante la transizione del Navigator.

In questo tutorial costruiremo una piccola app con una lista di piatti: toccando una card si aprirà il dettaglio con un'animazione condivisa su immagine e titolo. Vedremo anche gli errori più comuni (tag duplicati, immagini che "sfarfallano") e come personalizzare la durata della transizione.

Prerequisiti: conoscere le basi di Navigator.push, ListView e StatelessWidget.

  1. 1

    Preparare il progetto e il modello dati

    Creiamo un nuovo progetto e definiamo un semplice modello Dish con id, nome, descrizione e URL dell'immagine. L'id ci servirà anche come tag univoco per gli Hero.

    flutter create hero_demo
    cd hero_demo
    

    Per caricare immagini di rete servono i permessi Internet (su Android sono già presenti in debug). Crea il file lib/dish.dart con il modello e una lista di dati finti.

    // lib/dish.dart
    class Dish {
      final String id;
      final String name;
      final String description;
      final String imageUrl;
    
      const Dish({
        required this.id,
        required this.name,
        required this.description,
        required this.imageUrl,
      });
    }
    
    const demoDishes = <Dish>[
      Dish(
        id: 'carbonara',
        name: 'Carbonara',
        description:
            'Guanciale croccante, uovo, pecorino romano e pepe nero. Niente panna, mai.',
        imageUrl: 'https://picsum.photos/id/292/800/600',
      ),
      Dish(
        id: 'margherita',
        name: 'Pizza Margherita',
        description:
            'Pomodoro San Marzano, fiordilatte, basilico fresco e olio extravergine.',
        imageUrl: 'https://picsum.photos/id/1080/800/600',
      ),
      Dish(
        id: 'tiramisu',
        name: 'Tiramisù',
        description:
            'Savoiardi inzuppati nel caffè, crema al mascarpone e cacao amaro.',
        imageUrl: 'https://picsum.photos/id/431/800/600',
      ),
    ];

    Risultato atteso

    Il progetto compila e hai un file `dish.dart` con tre piatti di esempio pronti da mostrare.

  2. 2

    Costruire la lista con le card dei piatti

    Creiamo la schermata iniziale: una ListView.builder che mostra una card per ogni piatto, con immagine a sinistra e testo a destra. Per ora senza Hero: la aggiungeremo al passo successivo, così vedrai la differenza.

    Nota l'uso di InkWell per rendere l'intera card toccabile e di ClipRRect per arrotondare gli angoli dell'immagine.

    // lib/main.dart
    import 'package:flutter/material.dart';
    import 'dish.dart';
    import 'dish_detail_page.dart';
    
    void main() => runApp(const HeroDemoApp());
    
    class HeroDemoApp extends StatelessWidget {
      const HeroDemoApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'Hero Demo',
          theme: ThemeData(colorSchemeSeed: Colors.deepOrange, useMaterial3: true),
          home: const DishListPage(),
        );
      }
    }
    
    class DishListPage extends StatelessWidget {
      const DishListPage({super.key});
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: const Text('Menù del giorno')),
          body: ListView.builder(
            padding: const EdgeInsets.all(12),
            itemCount: demoDishes.length,
            itemBuilder: (context, index) {
              final dish = demoDishes[index];
              return Card(
                clipBehavior: Clip.antiAlias,
                margin: const EdgeInsets.only(bottom: 12),
                child: InkWell(
                  onTap: () {
                    Navigator.of(context).push(
                      MaterialPageRoute(
                        builder: (_) => DishDetailPage(dish: dish),
                      ),
                    );
                  },
                  child: Padding(
                    padding: const EdgeInsets.all(12),
                    child: Row(
                      children: [
                        ClipRRect(
                          borderRadius: BorderRadius.circular(12),
                          child: Image.network(
                            dish.imageUrl,
                            width: 90,
                            height: 90,
                            fit: BoxFit.cover,
                          ),
                        ),
                        const SizedBox(width: 16),
                        Expanded(
                          child: Text(
                            dish.name,
                            style: Theme.of(context).textTheme.titleMedium,
                          ),
                        ),
                        const Icon(Icons.chevron_right),
                      ],
                    ),
                  ),
                ),
              );
            },
          ),
        );
      }
    }

    Risultato atteso

    L'app mostra tre card cliccabili. Il tap causerà un errore finché non creiamo `DishDetailPage` nel passo successivo.

  3. 3

    Creare la schermata di dettaglio

    La pagina di dettaglio mostra l'immagine grande in alto, il nome e la descrizione. Anche qui, per ora, nessun Hero: prima facciamo funzionare la navigazione.

    Usiamo un CustomScrollView non necessario per un esempio così semplice: bastano una Column e un SingleChildScrollView per evitare overflow su schermi piccoli.

    // lib/dish_detail_page.dart
    import 'package:flutter/material.dart';
    import 'dish.dart';
    
    class DishDetailPage extends StatelessWidget {
      const DishDetailPage({super.key, required this.dish});
    
      final Dish dish;
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: Text(dish.name)),
          body: SingleChildScrollView(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Image.network(
                  dish.imageUrl,
                  width: double.infinity,
                  height: 260,
                  fit: BoxFit.cover,
                ),
                Padding(
                  padding: const EdgeInsets.all(20),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        dish.name,
                        style: Theme.of(context).textTheme.headlineSmall,
                      ),
                      const SizedBox(height: 12),
                      Text(
                        dish.description,
                        style: Theme.of(context).textTheme.bodyLarge,
                      ),
                    ],
                  ),
                ),
              ],
            ),
          ),
        );
      }
    }

    Risultato atteso

    Toccando una card si apre il dettaglio con la transizione standard di Material: la pagina entra da destra/dal basso, senza animazione condivisa.

  4. 4

    Aggiungere il widget Hero all'immagine

    Ora la parte interessante. Avvolgiamo l'immagine in entrambe le schermate con un Hero che usa lo stesso tag. Il tag deve essere:

    • univoco all'interno della schermata visibile (due Hero con lo stesso tag nella stessa pagina generano un errore);
    • identico tra pagina di partenza e pagina di arrivo.

    Usare dish.id è la scelta perfetta. Non usare mai l'index della lista se l'ordine può cambiare.

    Durante la transizione Flutter estrae il widget dal suo posto, lo mette in un overlay e lo anima dal rettangolo di partenza a quello di arrivo.

    // Nella lista (main.dart), sostituisci il ClipRRect con:
    Hero(
      tag: 'dish-image-${dish.id}',
      child: ClipRRect(
        borderRadius: BorderRadius.circular(12),
        child: Image.network(
          dish.imageUrl,
          width: 90,
          height: 90,
          fit: BoxFit.cover,
        ),
      ),
    ),
    
    // Nel dettaglio (dish_detail_page.dart), sostituisci Image.network con:
    Hero(
      tag: 'dish-image-${dish.id}',
      child: Image.network(
        dish.imageUrl,
        width: double.infinity,
        height: 260,
        fit: BoxFit.cover,
      ),
    ),

    Risultato atteso

    Toccando una card, la miniatura si ingrandisce volando fino alla posizione dell'immagine grande del dettaglio. Premendo indietro l'animazione si inverte automaticamente.

  5. 5

    Animare anche il titolo e risolvere gli errori tipici

    Possiamo animare più di un elemento per volta: aggiungiamo un Hero anche sul nome del piatto. C'è però un dettaglio: il Text cambia dimensione tra le due pagine e, durante il volo, il widget viene misurato con vincoli diversi — il risultato può essere un warning di overflow o un testo che "salta".

    La soluzione standard è avvolgere il testo in un Material trasparente e in un DefaultTextStyle/FittedBox, così il testo non eredita stili incoerenti e si adatta allo spazio disponibile.

    Errori comuni da ricordare:

    • «There are multiple heroes that share the same tag»: due Hero con lo stesso tag visibili contemporaneamente (tipico se usi un tag fisso in una lista). Usa sempre un id univoco.
    • Immagine che sfarfalla all'arrivo: succede quando la seconda pagina scarica di nuovo l'immagine. Usa cached_network_image o precarica con precacheImage.
    • Hero dentro un ListView non ancora costruito: se l'elemento di partenza non è visibile, l'animazione di ritorno non parte. È normale.
    // Widget riutilizzabile per il titolo animato
    class HeroTitle extends StatelessWidget {
      const HeroTitle({super.key, required this.tag, required this.text, required this.style});
    
      final String tag;
      final String text;
      final TextStyle? style;
    
      @override
      Widget build(BuildContext context) {
        return Hero(
          tag: tag,
          child: Material(
            type: MaterialType.transparency,
            child: Text(
              text,
              maxLines: 1,
              overflow: TextOverflow.ellipsis,
              style: style,
            ),
          ),
        );
      }
    }
    
    // Nella lista:
    Expanded(
      child: HeroTitle(
        tag: 'dish-title-${dish.id}',
        text: dish.name,
        style: Theme.of(context).textTheme.titleMedium,
      ),
    ),
    
    // Nel dettaglio:
    HeroTitle(
      tag: 'dish-title-${dish.id}',
      text: dish.name,
      style: Theme.of(context).textTheme.headlineSmall,
    ),

    Risultato atteso

    Immagine e titolo volano insieme tra le due schermate, senza warning di overflow né testo con stile sbagliato durante la transizione.

  6. 6

    Personalizzare la transizione: durata e forma del volo

    Due parametri rendono l'effetto molto più raffinato.

    1. La durata: non si imposta sull'Hero ma sulla PageRoute, perché l'Hero segue l'animazione della rotta. Creiamo una MaterialPageRoute con transitionDuration personalizzata estendendo PageRouteBuilder.
    2. flightShuttleBuilder: permette di decidere cosa mostrare durante il volo. È utile, per esempio, per mantenere gli angoli arrotondati mentre l'immagine si ingrandisce, invece di farli sparire di colpo.

    Prova a rallentare la transizione a 800 ms per osservare l'animazione al rallentatore: è il modo migliore per capire cosa succede.

    // Rotta con durata personalizzata
    Route<void> detailRoute(Dish dish) {
      return PageRouteBuilder<void>(
        transitionDuration: const Duration(milliseconds: 600),
        reverseTransitionDuration: const Duration(milliseconds: 450),
        pageBuilder: (_, __, ___) => DishDetailPage(dish: dish),
        transitionsBuilder: (_, animation, __, child) {
          return FadeTransition(opacity: animation, child: child);
        },
      );
    }
    
    // Uso nella lista:
    onTap: () => Navigator.of(context).push(detailRoute(dish)),
    
    // Hero con angoli arrotondati animati durante il volo
    Hero(
      tag: 'dish-image-${dish.id}',
      flightShuttleBuilder: (context, animation, direction, fromCtx, toCtx) {
        final radius = Tween<double>(begin: 12, end: 0).animate(animation);
        return AnimatedBuilder(
          animation: radius,
          builder: (context, _) => ClipRRect(
            borderRadius: BorderRadius.circular(radius.value),
            child: Image.network(dish.imageUrl, fit: BoxFit.cover),
          ),
        );
      },
      child: ClipRRect(
        borderRadius: BorderRadius.circular(12),
        child: Image.network(dish.imageUrl, width: 90, height: 90, fit: BoxFit.cover),
      ),
    )

    Risultato atteso

    La transizione dura 600 ms in andata e 450 ms al ritorno, con l'immagine che perde gradualmente gli angoli arrotondati mentre si ingrandisce.

  7. 7

    Rifinire: precaricare le immagini ed evitare lo sfarfallio

    Ultimo tocco di qualità. Con Image.network l'immagine grande viene scaricata quando la pagina di dettaglio si costruisce: se la connessione è lenta vedrai un rettangolo vuoto atterrare al posto della foto.

    Due rimedi:

    • precacheImage prima di navigare, così l'immagine è già nella cache di Flutter;
    • il pacchetto cached_network_image, che condivide la cache tra le due schermate.

    Inoltre, ricorda che Hero funziona con qualsiasi widget: icone, Container colorati, avatar, persino un FloatingActionButton che si trasforma in una schermata. Prova a sostituire l'immagine con un CircleAvatar per vedere l'effetto.

    onTap: () async {
      // Scarica l'immagine grande prima di aprire il dettaglio
      await precacheImage(NetworkImage(dish.imageUrl), context);
      if (!context.mounted) return;
      Navigator.of(context).push(detailRoute(dish));
    },

    Risultato atteso

    La transizione è fluida e continua: l'immagine non lampeggia all'arrivo sulla pagina di dettaglio, nemmeno con connessione lenta.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!