Quasi ogni app Flutter è fatta di superfici scorrevoli: liste, griglie, form lunghi, caroselli. Finché serve solo mostrare contenuti, ListView basta e avanza. I problemi arrivano quando bisogna controllare lo scroll: tornare in cima con un tasto, nascondere una bottom bar mentre l'utente scorre, agganciare un carosello alla card successiva, caricare altri dati quando si arriva in fondo.

In questa guida vediamo i tre strumenti che governano lo scorrimento in Flutter — ScrollController, ScrollNotification e ScrollPhysics — con esempi pronti all'uso e le trappole più comuni.

L'anatomia di uno scrollable

Dietro a un ListView ci sono tre attori:

  • Scrollable: il widget che riceve i gesti e trasforma i drag in offset.
  • ScrollPosition: lo stato dello scroll (offset corrente, estensioni minime e massime, direzione dell'utente). Contiene le metriche (ScrollMetrics).
  • ScrollController: l'oggetto che si attacca a una o più ScrollPosition e permette di leggerle e pilotarle dall'esterno.

Capire questa separazione evita il 90% degli errori: il controller non è lo scroll, è solo un telecomando che può essere collegato a zero, uno o più Scrollable.

ScrollController: leggere e pilotare

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

  @override
  State<ProductList> createState() => _ProductListState();
}

class _ProductListState extends State<ProductList> {
  final _controller = ScrollController();
  bool _showFab = false;

  @override
  void initState() {
    super.initState();
    _controller.addListener(_onScroll);
  }

  void _onScroll() {
    final shouldShow = _controller.offset > 400;
    if (shouldShow != _showFab) {
      setState(() => _showFab = shouldShow);
    }
  }

  @override
  void dispose() {
    _controller.removeListener(_onScroll);
    _controller.dispose(); // obbligatorio: è un ChangeNotifier
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: ListView.builder(
        controller: _controller,
        itemCount: 200,
        itemBuilder: (context, i) => ListTile(title: Text('Prodotto $i')),
      ),
      floatingActionButton: _showFab
          ? FloatingActionButton(
              onPressed: () => _controller.animateTo(
                0,
                duration: const Duration(milliseconds: 400),
                curve: Curves.easeOutCubic,
              ),
              child: const Icon(Icons.arrow_upward),
            )
          : null,
    );
  }
}

Punti chiave:

  • jumpTo() sposta istantaneamente, animateTo() anima con curva e durata.
  • Il listener del controller viene invocato a ogni frame di scroll: fai il minimo indispensabile e chiama setState solo quando il valore derivato cambia davvero (come nell'esempio sopra), altrimenti ricostruisci l'intera pagina 60 volte al secondo.
  • dispose() è obbligatorio, pena un memory leak segnalato anche da leak_tracker.

Attenzione a controller.position

Leggere _controller.position o _controller.offset prima che il widget sia stato montato lancia un'eccezione (ScrollController not attached to any scroll views). Se ti serve l'offset subito dopo il primo build, usa un post-frame callback:

WidgetsBinding.instance.addPostFrameCallback((_) {
  if (!_controller.hasClients) return;
  _controller.jumpTo(_controller.position.maxScrollExtent);
});

E ricorda: un controller collegato a più Scrollable (per esempio dentro un TabBarView con pagine riciclate) fa fallire position; in quel caso usa controller.positions o, molto meglio, un controller per ogni lista.

ScrollNotification: reagire senza listener continui

Quando la reazione allo scroll riguarda un sottoalbero (e non ti serve pilotare lo scroll), NotificationListener è più elegante di un controller: cattura le notifiche che risalgono l'albero, anche da liste annidate.

NotificationListener<ScrollNotification>(
  onNotification: (notification) {
    if (notification.depth != 0) return false; // ignora liste annidate

    if (notification is UserScrollNotification) {
      switch (notification.direction) {
        case ScrollDirection.reverse:
          _bottomBar.hide();
        case ScrollDirection.forward:
          _bottomBar.show();
        case ScrollDirection.idle:
          break;
      }
    }
    return false; // false = lascia propagare la notifica
  },
  child: const ProductList(),
)

I tipi principali sono:

  • ScrollStartNotification / ScrollEndNotification: inizio e fine di un'attività di scroll (drag o inerzia).
  • ScrollUpdateNotification: ogni variazione di offset, con scrollDelta.
  • OverscrollNotification: l'utente ha tirato oltre il bordo (utile per pull-to-refresh custom).
  • UserScrollNotification: cambia la direzione dell'interazione utente.

Trigger di paginazione affidabile

Un errore classico è caricare la pagina successiva dentro il listener del controller: la condizione diventa vera per decine di frame consecutivi e scattano chiamate duplicate. Usare ScrollEndNotification più una guardia è molto più solido:

bool _loading = false;

Future<void> _maybeLoadMore(ScrollMetrics m) async {
  if (_loading) return;
  if (m.extentAfter > 600) return; // mancano più di 600px alla fine
  _loading = true;
  try {
    await context.read<ProductsRepository>().loadNextPage();
  } finally {
    _loading = false;
  }
}

// ...
NotificationListener<ScrollEndNotification>(
  onNotification: (n) {
    _maybeLoadMore(n.metrics);
    return false;
  },
  child: list,
)

Le ScrollMetrics offrono tutto quello che serve: pixels, maxScrollExtent, extentBefore, extentAfter, atEdge, outOfRange, viewportDimension.

ScrollPhysics: cambiare il "feeling" dello scroll

Le physics decidono come l'offset risponde al dito e come si comporta l'inerzia. Le più usate:

  • BouncingScrollPhysics: rimbalzo stile iOS.
  • ClampingScrollPhysics: stop netto ai bordi, stile Android.
  • AlwaysScrollableScrollPhysics: permette il drag anche quando il contenuto è più corto del viewport (indispensabile con RefreshIndicator).
  • NeverScrollableScrollPhysics: disabilita lo scroll, tipico per liste annidate dentro un SingleChildScrollView.
  • PageScrollPhysics: aggancio a pagine intere, quello usato da PageView.

Si combinano tramite il parametro parent:

const AlwaysScrollableScrollPhysics(parent: BouncingScrollPhysics())

Physics personalizzate: snap su elementi a larghezza fissa

Per un carosello di card che deve fermarsi sempre allineato, PageScrollPhysics non basta (le card non occupano l'intero viewport). Ecco una physics di snap basata su un extent noto:

import 'dart:ui' show clampDouble;
import 'package:flutter/physics.dart';
import 'package:flutter/widgets.dart';

class SnapScrollPhysics extends ScrollPhysics {
  const SnapScrollPhysics({required this.itemExtent, super.parent});

  final double itemExtent;

  @override
  SnapScrollPhysics applyTo(ScrollPhysics? ancestor) =>
      SnapScrollPhysics(itemExtent: itemExtent, parent: buildParent(ancestor));

  double _targetPixels(ScrollMetrics position, double velocity, Tolerance tolerance) {
    var page = position.pixels / itemExtent;
    if (velocity < -tolerance.velocity) {
      page = page.floorToDouble();
    } else if (velocity > tolerance.velocity) {
      page = page.ceilToDouble();
    } else {
      page = page.roundToDouble();
    }
    return clampDouble(
      page * itemExtent,
      position.minScrollExtent,
      position.maxScrollExtent,
    );
  }

  @override
  Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) {
    // Fuori dai bordi lascia lavorare la physics genitore (overscroll/rimbalzo).
    if ((velocity <= 0 && position.pixels <= position.minScrollExtent) ||
        (velocity >= 0 && position.pixels >= position.maxScrollExtent)) {
      return super.createBallisticSimulation(position, velocity);
    }

    final tolerance = toleranceFor(position);
    final target = _targetPixels(position, velocity, tolerance);
    if ((target - position.pixels).abs() < tolerance.distance) return null;

    return ScrollSpringSimulation(
      spring,
      position.pixels,
      target,
      velocity,
      tolerance: tolerance,
    );
  }

  @override
  bool get allowImplicitScrolling => false;
}

Uso:

ListView.builder(
  scrollDirection: Axis.horizontal,
  physics: const SnapScrollPhysics(itemExtent: 280),
  itemExtent: 280,
  itemBuilder: (context, i) => PromoCard(index: i),
)

Due dettagli importanti: applyTo deve restituire una nuova istanza con lo stesso stato (Flutter lo chiama per comporre le physics della piattaforma), e toleranceFor(position) sostituisce il vecchio getter tolerance, ormai deprecato.

Altri override utili quando servono comportamenti più esotici: applyPhysicsToUserOffset (per ridurre la sensibilità del drag) e applyBoundaryConditions (per definire limiti o resistenze personalizzate).

ScrollBehavior: scroll con il mouse e scrollbar

Su web e desktop il drag con il mouse è disabilitato di default. Per abilitarlo — ad esempio in un carosello che deve essere trascinabile anche con il cursore — si personalizza lo ScrollBehavior:

class AppScrollBehavior extends MaterialScrollBehavior {
  @override
  Set<PointerDeviceKind> get dragDevices => {
        PointerDeviceKind.touch,
        PointerDeviceKind.mouse,
        PointerDeviceKind.trackpad,
        PointerDeviceKind.stylus,
      };
}

MaterialApp(
  scrollBehavior: AppScrollBehavior(),
  home: const HomePage(),
);

Puoi applicarlo a un solo sottoalbero con ScrollConfiguration(behavior: ..., child: ...) e, sempre da lì, sovrascrivere buildScrollbar o buildOverscrollIndicator per rimuovere il glow di Android o forzare la scrollbar.

PrimaryScrollController e scroll annidati

Ogni MaterialApp mette a disposizione un PrimaryScrollController. È quello che permette alla AppBar di rispondere al tap sulla status bar in iOS (scroll-to-top) e che NestedScrollView usa per coordinare header e body. Se in una pagina hai più liste verticali, imposta primary: false su quelle secondarie per evitare che due Scrollable litighino sullo stesso controller.

Per far scorrere la lista fino a un widget specifico, quando conosci il BuildContext dell'elemento:

Scrollable.ensureVisible(
  itemKey.currentContext!,
  duration: const Duration(milliseconds: 300),
  alignment: 0.1,
);

Funziona però solo per gli elementi già costruiti: con ListView.builder e liste lunghe conviene calcolare l'offset a mano (se l'extent è fisso) oppure valutare un pacchetto come scrollable_positioned_list.

Preservare la posizione di scroll

Due meccanismi spesso confusi:

  • PageStorageKey: salva l'offset quando il widget viene rimosso e ricostruito nella stessa sessione (tipico dei tab o delle pagine di una bottom navigation con IndexedStack).
  • restorationId: partecipa allo state restoration del sistema operativo, quindi sopravvive anche alla terminazione del processo.
ListView.builder(
  key: const PageStorageKey('tab_products'),
  restorationId: 'tab_products',
  itemBuilder: ...,
)

Checklist finale

  • Un ScrollController per ogni Scrollable, sempre con dispose().
  • Nel listener del controller, aggiorna lo stato solo quando il valore derivato cambia.
  • Per reagire allo scroll senza pilotarlo, preferisci NotificationListener e controlla notification.depth.
  • Usa ScrollEndNotification + flag di guardia per la paginazione, non il listener continuo.
  • Estendi ScrollPhysics (con applyTo corretto) per snap e comportamenti custom invece di simulare l'aggancio con animateTo dopo il gesto.
  • Configura ScrollBehavior se l'app gira su web o desktop.
  • Aggiungi PageStorageKey/restorationId dove la posizione conta per l'utente.

Controllare lo scroll in modo corretto è una di quelle cose che l'utente non nota mai — finché non funziona. Investire mezz'ora in queste API evita interfacce che "saltano", chiamate di rete duplicate e animazioni che scattano a metà.