Indicatori di caricamento in Flutter: CircularProgressIndicator e LinearProgressIndicator

Foto di Joel Durkee su Unsplash

GuidePrincipiante30 min Flutter 3.x

Indicatori di caricamento in Flutter: CircularProgressIndicator e LinearProgressIndicator

Ogni app, prima o poi, deve far aspettare l'utente: un download, una chiamata di rete, un salvataggio su database. Se durante l'attesa lo schermo resta fermo, l'utente pensa che l'app sia bloccata e spesso chiude tutto.

Flutter mette a disposizione due widget pronti all'uso per comunicare "sto lavorando":

  • CircularProgressIndicator: il classico cerchio che gira, ideale per attese brevi o di durata sconosciuta.
  • LinearProgressIndicator: una barra orizzontale, perfetta quando conosci la percentuale di avanzamento.

In questo tutorial partiamo da zero e costruiamo una piccola schermata dimostrativa in cui vedremo:

  1. la differenza tra indicatore indeterminato e determinato;
  2. come collegare un indicatore a un'operazione asincrona reale;
  3. come creare un bottone con loader integrato;
  4. come mostrare un dialog di caricamento bloccante e chiuderlo in sicurezza;
  5. le personalizzazioni grafiche e le best practice da seguire.

Non servono pacchetti esterni: tutto quello che useremo fa parte del Material di Flutter.

  1. 1

    Preparare il progetto e la schermata di base

    Creiamo un nuovo progetto e sostituiamo il contenuto di lib/main.dart con una schermata vuota che useremo come laboratorio.

    Da terminale:

    flutter create loading_demo
    cd loading_demo
    

    Il file di partenza definisce un MaterialApp con un Scaffold e una Column centrata: è lo spazio in cui aggiungeremo, passo dopo passo, i vari indicatori.

    Nota: usiamo useMaterial3: true perché gli indicatori di Material 3 hanno un aspetto leggermente diverso (spessori e arrotondamenti) rispetto a Material 2.

    import 'package:flutter/material.dart';
    
    void main() => runApp(const LoadingDemoApp());
    
    class LoadingDemoApp extends StatelessWidget {
      const LoadingDemoApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'Loading Demo',
          theme: ThemeData(
            colorSchemeSeed: Colors.indigo,
            useMaterial3: true,
          ),
          home: const HomePage(),
        );
      }
    }
    
    class HomePage extends StatefulWidget {
      const HomePage({super.key});
    
      @override
      State<HomePage> createState() => _HomePageState();
    }
    
    class _HomePageState extends State<HomePage> {
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: const Text('Indicatori di caricamento')),
          body: const Padding(
            padding: EdgeInsets.all(24),
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text('Qui aggiungeremo gli indicatori'),
              ],
            ),
          ),
        );
      }
    }

    Risultato atteso

    L'app si avvia mostrando una AppBar con il titolo e un testo segnaposto al centro dello schermo.

  2. 2

    Il CircularProgressIndicator: indeterminato e determinato

    CircularProgressIndicator ha due modalità:

    • indeterminato (comportamento di default): non passi il parametro value, il cerchio gira all'infinito. Si usa quando non sai quanto durerà l'operazione.
    • determinato: passi value con un numero tra 0.0 e 1.0, e l'arco si riempie in proporzione.

    Attenzione a un errore classico: se metti un indicatore dentro una Row o una Column senza vincoli, può occupare più spazio del previsto. La soluzione più semplice è racchiuderlo in un SizedBox con larghezza e altezza fisse.

    Sostituisci i children della Column con il codice qui sotto.

    children: [
      // 1. Indeterminato: gira all'infinito
      const SizedBox(
        width: 48,
        height: 48,
        child: CircularProgressIndicator(strokeWidth: 5),
      ),
      const SizedBox(height: 8),
      const Text('Indeterminato'),
    
      const SizedBox(height: 32),
    
      // 2. Determinato: mostra il 65% di avanzamento
      const SizedBox(
        width: 64,
        height: 64,
        child: CircularProgressIndicator(
          value: 0.65,
          strokeWidth: 8,
          backgroundColor: Color(0xFFE0E0E0),
        ),
      ),
      const SizedBox(height: 8),
      const Text('Determinato (65%)'),
    ],

    Risultato atteso

    Sullo schermo vedi due cerchi: il primo ruota continuamente, il secondo è fermo e riempito per circa due terzi.

  3. 3

    Collegare un LinearProgressIndicator a un avanzamento reale

    La barra lineare è la scelta giusta quando puoi calcolare una percentuale (download, upload, importazione di record).

    Simuliamo un'operazione che avanza a scatti: un Timer.periodic incrementa una variabile di stato _progress da 0.0 a 1.0. Ogni setState ridisegna la barra.

    Punti importanti:

    • _progress è un double nello State del widget, non una variabile globale;
    • quando arriviamo a 1.0 fermiamo il timer con cancel();
    • nel dispose() cancelliamo sempre il timer, altrimenti rischiamo un setState chiamato dopo che il widget è stato smontato.

    Ricorda di aggiungere import 'dart:async'; in cima al file.

    import 'dart:async';
    
    // ...dentro _HomePageState
    
    double _progress = 0.0;
    Timer? _timer;
    
    void _avviaDownload() {
      _timer?.cancel();
      setState(() => _progress = 0.0);
    
      _timer = Timer.periodic(const Duration(milliseconds: 300), (timer) {
        setState(() {
          _progress += 0.1;
          if (_progress >= 1.0) {
            _progress = 1.0;
            timer.cancel();
          }
        });
      });
    }
    
    @override
    void dispose() {
      _timer?.cancel();
      super.dispose();
    }
    
    // ...nel build, dentro la Column
    Widget _buildBarra() {
      final percentuale = (_progress * 100).toStringAsFixed(0);
      return Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          ClipRRect(
            borderRadius: BorderRadius.circular(8),
            child: LinearProgressIndicator(
              value: _progress,
              minHeight: 12,
              backgroundColor: const Color(0xFFE0E0E0),
            ),
          ),
          const SizedBox(height: 8),
          Text('Download: $percentuale%', textAlign: TextAlign.center),
          const SizedBox(height: 16),
          FilledButton(
            onPressed: _avviaDownload,
            child: const Text('Avvia download'),
          ),
        ],
      );
    }

    Risultato atteso

    Premendo "Avvia download" la barra si riempie progressivamente in circa 3 secondi e il testo mostra la percentuale aggiornata fino al 100%.

  4. 4

    Creare un bottone con loader integrato

    Il pattern più usato nelle app reali: l'utente preme "Salva", il bottone si disabilita e mostra uno spinner finché l'operazione non finisce. Così si evitano i doppi tap e si comunica che qualcosa sta accadendo.

    La ricetta è semplice:

    1. una variabile booleana _caricamento nello State;
    2. onPressed: _caricamento ? null : _salva — passando null il bottone si disabilita da solo;
    3. il child del bottone cambia tra spinner e testo.

    Lo spinner dentro un bottone deve essere piccolo: lo chiudiamo in un SizedBox di 18x18 con strokeWidth: 2 e colore coerente con il testo del bottone.

    Nota fondamentale: dopo un await, prima di chiamare setState, controlla sempre if (!mounted) return;. Se l'utente è uscito dalla schermata nel frattempo, eviti un errore in console.

    bool _caricamento = false;
    
    Future<void> _salva() async {
      setState(() => _caricamento = true);
      try {
        // Simula una chiamata di rete
        await Future<void>.delayed(const Duration(seconds: 2));
        if (!mounted) return;
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('Salvataggio completato')),
        );
      } finally {
        if (mounted) setState(() => _caricamento = false);
      }
    }
    
    Widget _buildBottone() {
      return FilledButton(
        onPressed: _caricamento ? null : _salva,
        child: _caricamento
            ? const SizedBox(
                width: 18,
                height: 18,
                child: CircularProgressIndicator(
                  strokeWidth: 2,
                  color: Colors.white,
                ),
              )
            : const Text('Salva'),
      );
    }

    Risultato atteso

    Al tap il bottone diventa grigio e mostra un piccolo spinner per 2 secondi; poi torna normale e compare una SnackBar di conferma.

  5. 5

    Mostrare un dialog di caricamento bloccante

    Quando l'operazione blocca davvero tutta la schermata (es. il login), puoi mostrare un dialog non chiudibile.

    Gli ingredienti sono:

    • barrierDismissible: false per impedire la chiusura toccando fuori;
    • PopScope con canPop: false per bloccare anche il tasto "indietro" di Android;
    • Navigator.of(context, rootNavigator: true).pop() per chiuderlo al termine.

    Attenzione all'errore più comune: chiudere il dialog dentro un finally senza verificare che sia ancora aperto. Per questo salviamo il Navigator in una variabile prima dell'await e usiamo mounted.

    Future<void> _loginConDialog() async {
      showDialog<void>(
        context: context,
        barrierDismissible: false,
        builder: (_) => const PopScope(
          canPop: false,
          child: AlertDialog(
            content: Row(
              mainAxisSize: MainAxisSize.min,
              children: [
                SizedBox(
                  width: 24,
                  height: 24,
                  child: CircularProgressIndicator(strokeWidth: 3),
                ),
                SizedBox(width: 20),
                Expanded(child: Text('Accesso in corso...')),
              ],
            ),
          ),
        ),
      );
    
      final navigator = Navigator.of(context, rootNavigator: true);
    
      try {
        await Future<void>.delayed(const Duration(seconds: 3));
      } finally {
        if (mounted) navigator.pop(); // chiude il dialog
      }
    }

    Risultato atteso

    Appare un dialog con spinner e testo "Accesso in corso..."; per 3 secondi non è possibile chiuderlo né con il tap fuori né con il tasto indietro, poi sparisce da solo.

  6. 6

    Personalizzare aspetto e adattarsi alla piattaforma

    Gli indicatori prendono di default il colore primario del tema. Puoi intervenire a due livelli:

    Sul singolo widget: color, backgroundColor, strokeWidth, minHeight, oppure valueColor con un AlwaysStoppedAnimation<Color> per casi particolari.

    Su tutta l'app, tramite ProgressIndicatorThemeData nel ThemeData: così non devi ripetere gli stessi colori ovunque.

    Se vuoi lo spinner nativo di iOS puoi usare CupertinoActivityIndicator, oppure il comodo .adaptive(), che sceglie automaticamente lo stile giusto in base alla piattaforma.

    // 1. Tema globale
    theme: ThemeData(
      colorSchemeSeed: Colors.indigo,
      useMaterial3: true,
      progressIndicatorTheme: const ProgressIndicatorThemeData(
        color: Colors.indigo,
        linearTrackColor: Color(0xFFE8EAF6),
        linearMinHeight: 10,
      ),
    ),
    
    // 2. Spinner adattivo: Material su Android/web, Cupertino su iOS/macOS
    const SizedBox(
      width: 32,
      height: 32,
      child: CircularProgressIndicator.adaptive(),
    ),
    
    // 3. Colore personalizzato solo su un widget
    const LinearProgressIndicator(
      value: 0.4,
      color: Colors.orange,
      backgroundColor: Color(0xFFFFE0B2),
    ),

    Risultato atteso

    Tutti gli indicatori dell'app adottano i colori del tema; su iOS lo spinner adattivo mostra lo stile nativo Cupertino.

  7. 7

    Best practice e errori da evitare

    Chiudiamo con le regole d'oro imparate sul campo.

    1. Non lasciare mai un loader senza via d'uscita. Avvolgi sempre l'operazione in try/catch/finally e ripristina lo stato anche in caso di errore, altrimenti l'utente resta bloccato davanti a uno spinner eterno.

    2. Controlla mounted dopo ogni await. È la causa numero uno dell'errore "setState() called after dispose()".

    3. Usa l'indicatore determinato quando puoi. Se conosci la percentuale, mostrala: l'attesa percepita si riduce drasticamente.

    4. Per attese sotto i 300 ms non mostrare nulla. Uno spinner che lampeggia per un istante è più fastidioso di nessun feedback.

    5. Considera lo scheletro (skeleton) al posto dello spinner per il primo caricamento di liste: l'utente capisce subito la struttura dei contenuti.

    6. Accessibilità: avvolgi il loader in un Semantics con un'etichetta, così gli screen reader annunciano lo stato di attesa.

    Qui sotto, un widget riutilizzabile che incapsula queste regole: mostralo al posto del contenuto quando isLoading è true.

    class LoadingOverlay extends StatelessWidget {
      const LoadingOverlay({
        super.key,
        required this.isLoading,
        required this.child,
        this.messaggio = 'Caricamento in corso',
      });
    
      final bool isLoading;
      final Widget child;
      final String messaggio;
    
      @override
      Widget build(BuildContext context) {
        return Stack(
          children: [
            child,
            if (isLoading)
              Positioned.fill(
                child: ColoredBox(
                  color: Colors.black38,
                  child: Center(
                    child: Semantics(
                      label: messaggio,
                      liveRegion: true,
                      child: const SizedBox(
                        width: 48,
                        height: 48,
                        child: CircularProgressIndicator(),
                      ),
                    ),
                  ),
                ),
              ),
          ],
        );
      }
    }
    
    // Uso:
    // body: LoadingOverlay(
    //   isLoading: _caricamento,
    //   child: ListView(children: [...]),
    // ),

    Risultato atteso

    Hai un componente riutilizzabile che oscura la schermata e mostra uno spinner accessibile durante le operazioni lunghe, pronto da riusare in tutta l'app.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!