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ùScrollPositione 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
setStatesolo 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 daleak_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, conscrollDelta.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 conRefreshIndicator).NeverScrollableScrollPhysics: disabilita lo scroll, tipico per liste annidate dentro unSingleChildScrollView.PageScrollPhysics: aggancio a pagine intere, quello usato daPageView.
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 conIndexedStack).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
ScrollControllerper ogniScrollable, sempre condispose(). - Nel listener del controller, aggiorna lo stato solo quando il valore derivato cambia.
- Per reagire allo scroll senza pilotarlo, preferisci
NotificationListenere controllanotification.depth. - Usa
ScrollEndNotification+ flag di guardia per la paginazione, non il listener continuo. - Estendi
ScrollPhysics(conapplyTocorretto) per snap e comportamenti custom invece di simulare l'aggancio conanimateTodopo il gesto. - Configura
ScrollBehaviorse l'app gira su web o desktop. - Aggiungi
PageStorageKey/restorationIddove 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à.