Liste infinite con paginazione in Flutter usando ListView e ScrollController
GuideIntermedio35 min Flutter 3.x

Liste infinite con paginazione in Flutter usando ListView e ScrollController

Quando un'app deve mostrare grandi quantità di dati provenienti da un'API, caricare tutto in una volta è inefficiente e lento. La soluzione è la paginazione, ovvero il caricamento progressivo dei dati a blocchi (pagine) man mano che l'utente scorre la lista.

In questo tutorial costruiremo una lista a scorrimento infinito (infinite scroll) usando ListView.builder combinato con uno ScrollController per rilevare quando l'utente si avvicina alla fine della lista. Gestiremo anche gli stati di caricamento e la fine dei dati disponibili.

Non useremo pacchetti esterni: tutto ciò che serve è già incluso in Flutter.

  1. 1

    Preparare il servizio dati simulato

    Per concentrarci sulla logica di paginazione, simuliamo un'API che restituisce blocchi di dati. In un progetto reale questa funzione effettuerebbe una chiamata HTTP passando i parametri di pagina (page) e dimensione (limit).

    La funzione introduce un ritardo artificiale per simulare la latenza di rete e restituisce 20 elementi per pagina, fermandosi dopo la pagina 5 per simulare la fine dei dati.

    class ItemRepository {
      static const int pageSize = 20;
      static const int maxPages = 5;
    
      Future<List<String>> fetchItems(int page) async {
        // Simula la latenza di rete
        await Future.delayed(const Duration(seconds: 1));
    
        // Nessun dato oltre l'ultima pagina
        if (page > maxPages) return [];
    
        final start = (page - 1) * pageSize;
        return List.generate(
          pageSize,
          (i) => 'Elemento ${start + i + 1}',
        );
      }
    }

    Risultato atteso

    Una classe ItemRepository che restituisce 20 elementi per pagina, fino a 5 pagine.

  2. 2

    Creare lo StatefulWidget e le variabili di stato

    Creiamo un StatefulWidget che conterrà la lista degli elementi caricati e le variabili necessarie a gestire la paginazione:

    • _items: la lista cumulativa degli elementi caricati;
    • _currentPage: la pagina corrente;
    • _isLoading: indica se è in corso un caricamento (evita richieste duplicate);
    • _hasMore: indica se ci sono altri dati da caricare;
    • _scrollController: controlla la posizione di scorrimento.
    class InfiniteListPage extends StatefulWidget {
      const InfiniteListPage({super.key});
    
      @override
      State<InfiniteListPage> createState() => _InfiniteListPageState();
    }
    
    class _InfiniteListPageState extends State<InfiniteListPage> {
      final ItemRepository _repository = ItemRepository();
      final ScrollController _scrollController = ScrollController();
    
      final List<String> _items = [];
      int _currentPage = 1;
      bool _isLoading = false;
      bool _hasMore = true;
    
      @override
      void dispose() {
        _scrollController.dispose();
        super.dispose();
      }
    }

    Risultato atteso

    La struttura dello State con tutte le variabili necessarie alla paginazione.

  3. 3

    Implementare la logica di caricamento

    Scriviamo il metodo _loadItems che recupera la pagina corrente e aggiunge gli elementi alla lista.

    È fondamentale controllare _isLoading e _hasMore all'inizio per evitare chiamate multiple sovrapposte. Quando l'API restituisce meno elementi di pageSize (o una lista vuota), impostiamo _hasMore a false per fermare ulteriori caricamenti.

    Future<void> _loadItems() async {
      if (_isLoading || !_hasMore) return;
    
      setState(() => _isLoading = true);
    
      final newItems = await _repository.fetchItems(_currentPage);
    
      if (!mounted) return;
    
      setState(() {
        _items.addAll(newItems);
        _isLoading = false;
        _currentPage++;
        if (newItems.length < ItemRepository.pageSize) {
          _hasMore = false;
        }
      });
    }

    Risultato atteso

    Un metodo che carica una pagina alla volta e aggiorna correttamente lo stato.

  4. 4

    Rilevare lo scroll e caricare la prima pagina

    Nel metodo initState carichiamo la prima pagina e aggiungiamo un listener allo ScrollController. Il listener controlla la distanza dal fondo della lista: quando l'utente è a meno di 200 pixel dalla fine, avviamo il caricamento della pagina successiva.

    Usare una soglia (in questo caso 200px) anziché aspettare il fondo esatto rende lo scorrimento più fluido, perché i nuovi dati vengono caricati in anticipo.

    @override
    void initState() {
      super.initState();
      _loadItems();
      _scrollController.addListener(_onScroll);
    }
    
    void _onScroll() {
      final position = _scrollController.position;
      if (position.pixels >= position.maxScrollExtent - 200) {
        _loadItems();
      }
    }

    Risultato atteso

    La prima pagina viene caricata all'avvio e lo scroll attiva i caricamenti successivi.

  5. 5

    Costruire la UI con ListView.builder

    Ora costruiamo l'interfaccia. Usiamo ListView.builder collegato allo _scrollController.

    Il conteggio degli elementi (itemCount) include un elemento extra in fondo quando _hasMore è true: questo elemento mostra un indicatore di caricamento. Quando l'indice supera la lunghezza della lista, mostriamo il CircularProgressIndicator, altrimenti la riga con il dato.

    @override
    Widget build(BuildContext context) {
      return Scaffold(
        appBar: AppBar(title: const Text('Lista infinita')),
        body: ListView.builder(
          controller: _scrollController,
          itemCount: _items.length + (_hasMore ? 1 : 0),
          itemBuilder: (context, index) {
            if (index >= _items.length) {
              return const Padding(
                padding: EdgeInsets.all(16),
                child: Center(child: CircularProgressIndicator()),
              );
            }
            return ListTile(
              leading: CircleAvatar(child: Text('${index + 1}')),
              title: Text(_items[index]),
            );
          },
        ),
      );
    }

    Risultato atteso

    Una lista scorrevole che mostra gli elementi e un indicatore di caricamento in fondo durante il fetch.

  6. 6

    Aggiungere il pull-to-refresh (opzionale)

    Per migliorare l'esperienza utente, avvolgiamo la ListView in un RefreshIndicator che permette di ricaricare la lista dall'inizio trascinandola verso il basso.

    Il metodo _refresh azzera tutte le variabili di stato e ricarica la prima pagina.

    Future<void> _refresh() async {
      setState(() {
        _items.clear();
        _currentPage = 1;
        _hasMore = true;
      });
      await _loadItems();
    }
    
    // Nel build, avvolgi la ListView:
    // body: RefreshIndicator(
    //   onRefresh: _refresh,
    //   child: ListView.builder( ... ),
    // ),

    Risultato atteso

    Trascinando la lista verso il basso, i dati vengono ricaricati dall'inizio.

  7. 7

    Gestire la fine dei dati e gli errori

    Come tocco finale, miglioriamo l'esperienza mostrando un messaggio quando non ci sono più dati e gestendo eventuali errori di rete con un blocco try/catch.

    In produzione è buona pratica salvare lo stato di errore e mostrare un pulsante "Riprova" per ritentare il caricamento della pagina fallita.

    Future<void> _loadItems() async {
      if (_isLoading || !_hasMore) return;
      setState(() => _isLoading = true);
    
      try {
        final newItems = await _repository.fetchItems(_currentPage);
        if (!mounted) return;
        setState(() {
          _items.addAll(newItems);
          _currentPage++;
          if (newItems.length < ItemRepository.pageSize) _hasMore = false;
        });
      } catch (e) {
        if (mounted) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text('Errore di caricamento: $e')),
          );
        }
      } finally {
        if (mounted) setState(() => _isLoading = false);
      }
    }

    Risultato atteso

    La lista gestisce correttamente la fine dei dati e mostra una SnackBar in caso di errore.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!