Introduzione

Quando un'app deve mostrare grandi quantità di dati provenienti da un backend, caricare tutto in una volta è inefficiente e dannoso per le performance e per l'esperienza utente. La soluzione più diffusa è la paginazione infinita (o infinite scroll): i dati vengono caricati a blocchi man mano che l'utente scorre la lista.

Implementare questa logica a mano richiede di gestire lo stato di caricamento, gli errori, la pagina corrente, il rilevamento della fine della lista e i casi limite. Il pacchetto infinite_scroll_pagination semplifica enormemente tutto questo.

Installazione

Aggiungi la dipendenza al pubspec.yaml:

dependencies:
  infinite_scroll_pagination: ^4.0.0

Poi esegui flutter pub get.

Concetti fondamentali

Il cuore del pacchetto è il PagingController, che gestisce lo stato della lista paginata. Espone:

  • itemList: gli elementi caricati finora
  • nextPageKey: la chiave (pagina, cursore, offset) della prossima pagina
  • error: eventuale errore di caricamento

I widget PagedListView, PagedGridView e PagedSliverList si occupano di renderizzare la lista e di richiamare il caricamento quando serve.

Un esempio completo

Supponiamo di avere un servizio che restituisce una lista paginata di articoli.

import 'package:flutter/material.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';

class Articolo {
  final int id;
  final String titolo;
  Articolo({required this.id, required this.titolo});
}

class ArticoliPage extends StatefulWidget {
  const ArticoliPage({super.key});

  @override
  State<ArticoliPage> createState() => _ArticoliPageState();
}

class _ArticoliPageState extends State<ArticoliPage> {
  static const _pageSize = 20;

  final PagingController<int, Articolo> _pagingController =
      PagingController(firstPageKey: 0);

  @override
  void initState() {
    super.initState();
    _pagingController.addPageRequestListener((pageKey) {
      _fetchPage(pageKey);
    });
  }

  Future<void> _fetchPage(int pageKey) async {
    try {
      final nuoviArticoli = await _apiFetch(pageKey, _pageSize);
      final isLastPage = nuoviArticoli.length < _pageSize;
      if (isLastPage) {
        _pagingController.appendLastPage(nuoviArticoli);
      } else {
        final nextPageKey = pageKey + nuoviArticoli.length;
        _pagingController.appendPage(nuoviArticoli, nextPageKey);
      }
    } catch (error) {
      _pagingController.error = error;
    }
  }

  // Simulazione di una chiamata API
  Future<List<Articolo>> _apiFetch(int offset, int limit) async {
    await Future.delayed(const Duration(seconds: 1));
    return List.generate(
      limit,
      (i) => Articolo(id: offset + i, titolo: 'Articolo ${offset + i}'),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Articoli')),
      body: RefreshIndicator(
        onRefresh: () => Future.sync(() => _pagingController.refresh()),
        child: PagedListView<int, Articolo>(
          pagingController: _pagingController,
          builderDelegate: PagedChildBuilderDelegate<Articolo>(
            itemBuilder: (context, articolo, index) => ListTile(
              title: Text(articolo.titolo),
            ),
            firstPageErrorIndicatorBuilder: (_) => Center(
              child: TextButton(
                onPressed: () => _pagingController.refresh(),
                child: const Text('Errore, riprova'),
              ),
            ),
            noItemsFoundIndicatorBuilder: (_) =>
                const Center(child: Text('Nessun articolo')),
          ),
        ),
      ),
    );
  }

  @override
  void dispose() {
    _pagingController.dispose();
    super.dispose();
  }
}

Cosa succede

  1. Il PagingController viene inizializzato con firstPageKey: 0.
  2. Registriamo un listener che viene invocato ogni volta che serve caricare una pagina.
  3. Se il numero di elementi ricevuti è inferiore a _pageSize, siamo all'ultima pagina e usiamo appendLastPage; altrimenti appendPage con la chiave successiva.
  4. In caso di errore assegniamo _pagingController.error, e il widget mostra l'indicatore di errore.

Pull-to-refresh

Avvolgendo il PagedListView in un RefreshIndicator e chiamando _pagingController.refresh() è possibile ricaricare l'intera lista dal principio, molto utile per aggiornare i contenuti.

Gestire i filtri e la ricerca

Quando cambia un filtro o una query di ricerca è sufficiente chiamare _pagingController.refresh() dopo aver aggiornato lo stato del filtro. Il controller ripartirà dalla firstPageKey usando i nuovi parametri all'interno di _fetchPage.

void onSearchChanged(String query) {
  _query = query;
  _pagingController.refresh();
}

PagedGridView e Slivers

Oltre alle liste, il pacchetto offre PagedGridView per griglie e PagedSliverList / PagedSliverGrid per integrare la paginazione all'interno di un CustomScrollView, ad esempio con header collassabili.

Best practice

  • Dimensione della pagina adeguata: 15-25 elementi è un buon compromesso tra numero di richieste e quantità di dati.
  • Debounce sulla ricerca: evita di chiamare refresh() ad ogni carattere digitato.
  • Gestione degli errori chiara: fornisci sempre firstPageErrorIndicatorBuilder e newPageErrorIndicatorBuilder.
  • Dispose del controller: ricordati sempre di rilasciarlo nel dispose().

Conclusione

Con infinite_scroll_pagination puoi implementare liste paginate robuste con poche righe di codice, delegando al pacchetto la gestione degli stati più complessi. Combinato con Dio o Retrofit per le chiamate di rete e con un pattern di gestione dello stato come Riverpod o BLoC, diventa uno strumento essenziale per app che consumano API con grandi dataset.