Effetti parallax nelle liste in Flutter con Flow e ScrollController

Foto di Marek Piwnicki su Unsplash

GuideIntermedio35 min Flutter 3.x

Effetti parallax nelle liste in Flutter con Flow e ScrollController

L'effetto parallax dona profondità e dinamismo alle interfacce: mentre l'utente scorre, gli elementi in primo piano e sullo sfondo si muovono a velocità diverse.

In questo tutorial costruiremo una lista di card con immagini che scorrono più lentamente rispetto al contenuto, creando un piacevole effetto parallax. Useremo ScrollController per intercettare la posizione dello scroll e Flow (oppure una semplice trasformazione) per traslare l'immagine in base alla posizione della card nella viewport.

Non servono pacchetti esterni: tutto è realizzato con widget nativi di Flutter.

  1. 1

    Preparare il modello dati e la lista di elementi

    Iniziamo definendo un semplice modello ParallaxItem che contiene il titolo, il sottotitolo e l'URL dell'immagine. Creiamo poi una lista di esempio da mostrare.

    Usare immagini di rete (via Image.network) è comodo per il prototipo, ma nulla vieta di sostituirle con asset locali.

    class ParallaxItem {
      final String title;
      final String subtitle;
      final String imageUrl;
    
      const ParallaxItem({
        required this.title,
        required this.subtitle,
        required this.imageUrl,
      });
    }
    
    const demoItems = <ParallaxItem>[
      ParallaxItem(
        title: 'Montagne',
        subtitle: 'Alpi italiane',
        imageUrl: 'https://picsum.photos/id/1018/1200/800',
      ),
      ParallaxItem(
        title: 'Foresta',
        subtitle: 'Boschi del nord',
        imageUrl: 'https://picsum.photos/id/1015/1200/800',
      ),
      ParallaxItem(
        title: 'Lago',
        subtitle: 'Riflessi al tramonto',
        imageUrl: 'https://picsum.photos/id/1016/1200/800',
      ),
      ParallaxItem(
        title: 'Deserto',
        subtitle: 'Dune infinite',
        imageUrl: 'https://picsum.photos/id/1019/1200/800',
      ),
    ];

    Risultato atteso

    Abbiamo un modello dati e una lista di elementi di esempio pronti da renderizzare.

  2. 2

    Creare la struttura base con ListView e ScrollController

    Costruiamo uno StatefulWidget che ospita un ScrollController. È fondamentale creare il controller in initState e liberarlo in dispose per evitare memory leak.

    Assegniamo il controller alla ListView.builder in modo da poter leggere la posizione dello scroll nei passi successivi.

    class ParallaxListPage extends StatefulWidget {
      const ParallaxListPage({super.key});
    
      @override
      State<ParallaxListPage> createState() => _ParallaxListPageState();
    }
    
    class _ParallaxListPageState extends State<ParallaxListPage> {
      final ScrollController _controller = ScrollController();
    
      @override
      void dispose() {
        _controller.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: const Text('Parallax List')),
          body: ListView.builder(
            controller: _controller,
            padding: const EdgeInsets.symmetric(vertical: 16),
            itemCount: demoItems.length,
            itemBuilder: (context, index) {
              return ParallaxCard(
                item: demoItems[index],
                scrollController: _controller,
              );
            },
          ),
        );
      }
    }

    Risultato atteso

    La schermata mostra una lista scorrevole; le card (ancora da definire) riceveranno il controller.

  3. 3

    Calcolare la posizione della card nella viewport

    Il cuore dell'effetto parallax è capire dove si trova ogni card rispetto allo schermo. Usiamo il RenderObject della card e quello della lista per calcolare un valore normalizzato tra 0 e 1.

    • 0 significa che la card è appena entrata dal fondo dello schermo.
    • 1 significa che sta per uscire in alto.

    Questo valore ci servirà per traslare l'immagine.

    double _calculateParallaxFactor(GlobalKey itemKey, ScrollController controller) {
      final itemContext = itemKey.currentContext;
      if (itemContext == null) return 0.5;
    
      final RenderBox box = itemContext.findRenderObject() as RenderBox;
      // posizione globale del top della card
      final Offset position = box.localToGlobal(Offset.zero);
      final double screenHeight = WidgetsBinding
          .instance.platformDispatcher.views.first.physicalSize.height /
          WidgetsBinding.instance.platformDispatcher.views.first.devicePixelRatio;
    
      // normalizziamo tra 0 (in basso) e 1 (in alto)
      final double fraction =
          (position.dy / screenHeight).clamp(0.0, 1.0);
      return fraction;
    }

    Risultato atteso

    Abbiamo una funzione che restituisce un valore normalizzato in base alla posizione verticale della card.

  4. 4

    Costruire la ParallaxCard con l'effetto di traslazione

    Ora creiamo il widget ParallaxCard. Ascoltiamo lo scroll tramite AnimatedBuilder collegato allo ScrollController: ogni volta che il controller cambia, ricostruiamo solo la parte necessaria.

    Usiamo un ClipRRect per arrotondare gli angoli e posizioniamo l'immagine con OverflowBox, così da avere spazio extra per traslarla senza mostrare bordi vuoti. La traslazione verticale è calcolata dal fattore ottenuto nel passo precedente.

    class ParallaxCard extends StatelessWidget {
      final ParallaxItem item;
      final ScrollController scrollController;
    
      ParallaxCard({
        super.key,
        required this.item,
        required this.scrollController,
      });
    
      final GlobalKey _cardKey = GlobalKey();
    
      @override
      Widget build(BuildContext context) {
        return Padding(
          key: _cardKey,
          padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
          child: ClipRRect(
            borderRadius: BorderRadius.circular(16),
            child: SizedBox(
              height: 220,
              child: Stack(
                fit: StackFit.expand,
                children: [
                  AnimatedBuilder(
                    animation: scrollController,
                    builder: (context, _) {
                      final factor =
                          _calculateParallaxFactor(_cardKey, scrollController);
                      // massimo spostamento in pixel
                      final double dy = (factor - 0.5) * 80;
                      return OverflowBox(
                        maxHeight: 300,
                        child: Transform.translate(
                          offset: Offset(0, dy),
                          child: Image.network(
                            item.imageUrl,
                            fit: BoxFit.cover,
                          ),
                        ),
                      );
                    },
                  ),
                  _buildGradientOverlay(),
                  _buildTexts(),
                ],
              ),
            ),
          ),
        );
      }
    
      Widget _buildGradientOverlay() {
        return DecoratedBox(
          decoration: const BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
              colors: [Colors.transparent, Colors.black54],
            ),
          ),
        );
      }
    
      Widget _buildTexts() {
        return Positioned(
          left: 16,
          bottom: 16,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(
                item.title,
                style: const TextStyle(
                  color: Colors.white,
                  fontSize: 22,
                  fontWeight: FontWeight.bold,
                ),
              ),
              Text(
                item.subtitle,
                style: const TextStyle(color: Colors.white70, fontSize: 14),
              ),
            ],
          ),
        );
      }
    }

    Risultato atteso

    Scorrendo la lista, le immagini si muovono più lentamente rispetto alle card creando l'effetto parallax.

  5. 5

    Ottimizzare le prestazioni con RepaintBoundary

    L'AnimatedBuilder ricostruisce la card ad ogni frame di scroll. Per evitare di ridisegnare parti costose (come i testi statici e il gradiente), avvolgiamo l'immagine parallax in un RepaintBoundary.

    Inoltre spostiamo i widget che non cambiano fuori dal builder dell'AnimatedBuilder usando il parametro child, così Flutter li riusa senza ricostruirli.

    AnimatedBuilder(
      animation: scrollController,
      child: RepaintBoundary(
        child: Image.network(item.imageUrl, fit: BoxFit.cover),
      ),
      builder: (context, child) {
        final factor = _calculateParallaxFactor(_cardKey, scrollController);
        final double dy = (factor - 0.5) * 80;
        return OverflowBox(
          maxHeight: 300,
          child: Transform.translate(
            offset: Offset(0, dy),
            child: child, // riusa il widget immagine senza ricrearlo
          ),
        );
      },
    )

    Risultato atteso

    L'effetto rimane fluido anche con molte card, riducendo il carico di rendering.

  6. 6

    Rifinire l'esperienza con physics ed effetto sui testi

    Come tocco finale possiamo aggiungere un BouncingScrollPhysics alla lista per uno scroll più elastico e applicare una leggera traslazione anche ai testi, in direzione opposta all'immagine, per accentuare la profondità.

    Questo rende l'insieme più vivo: mentre lo sfondo scende, il testo sale leggermente.

    // Nella ListView
    ListView.builder(
      controller: _controller,
      physics: const BouncingScrollPhysics(),
      itemCount: demoItems.length,
      itemBuilder: (context, index) => ParallaxCard(
        item: demoItems[index],
        scrollController: _controller,
      ),
    );
    
    // Testi con parallax opposto (dentro un AnimatedBuilder analogo)
    // dyText = (factor - 0.5) * -20;
    // Transform.translate(offset: Offset(0, dyText), child: _buildTexts());

    Risultato atteso

    Lo scorrimento è elastico e testi e immagini si muovono in direzioni opposte, per un effetto parallax completo e curato.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!