ScrollController Patterns in Flutter
Practical ScrollController patterns for Flutter: lifecycle, multi-position pitfalls, jumpTo versus animateTo, notifications, and lazy loading.
Published on • October 16, 2026
AI Assistant

A ScrollController looks deceptively small: create it, pass it to a ListView, read .offset. Then you ship a screen with a collapsing app bar, a chat list that loads older messages, and a “back to top” button, and suddenly you are debugging an exception about multiple scroll views, a listener that rebuilds every frame, and an offset that reads zero before the first layout. This post walks through the patterns that keep scroll logic predictable: where the controller lives, what it actually attaches to, how to move the viewport, how to observe it without thrashing the widget tree, and how to piggyback pagination on scroll extent.
Keep the controller in State, and dispose it
The API reference is blunt about ownership: scroll controllers are typically stored as member variables in State objects and reused in each State.build. Create it once, never inside build, so you do not hand a fresh controller to the scrollable on every rebuild and silently reset the user’s position.
class FeedPage extends StatefulWidget {
const FeedPage({super.key});
@override
State<FeedPage> createState() => _FeedPageState();
}
class _FeedPageState extends State<FeedPage> {
final ScrollController _controller = ScrollController(
debugLabel: 'feed',
initialScrollOffset: 0,
keepScrollOffset: true,
);
@override
void initState() {
super.initState();
_controller.addListener(_onScroll);
}
@override
void dispose() {
_controller.removeListener(_onScroll);
_controller.dispose(); // overrides ChangeNotifier.dispose
super.dispose();
}
void _onScroll() { /* see the pagination section */ }
@override
Widget build(BuildContext context) {
return ListView.builder(
controller: _controller,
itemCount: 100,
itemBuilder: (context, index) => ListTile(title: Text('Row $index')),
);
}
}
Two constructor options are worth knowing. keepScrollOffset (default true) saves the offset with PageStorage after each scroll and restores it when the scrollable is recreated; pair it with a PageStorageKey on the widget to disambiguate several scroll views. debugLabel shows up in toString output, which makes “which controller is this?” tractable in DevTools.
Attach, detach, and the multi-position trap
A controller creates one ScrollPosition per attached Scrollable. That single fact explains most ScrollController bugs.
Reading controller.offset (or controller.position) is only legal when exactly one position is attached. Give the same controller to a ListView and a GridView on the same screen and the first read throws the familiar “ScrollController is attached to multiple scroll views” error. The docs allow one controller to drive several scrollables, but require a single scrollable for offset reads. Defensive code looks like this:
double get _offset {
if (!_controller.hasClients) return 0;
if (_controller.positions.length != 1) return 0;
return _controller.offset;
}
When you genuinely need to address each scrollable, iterate controller.positions and branch on what you find instead of assuming a single one.
attach and detach are also observable. The controller does not notify its listeners when the set of positions changes, so the constructor takes onAttach and onDetach callbacks:
final ScrollController _controller = ScrollController(
onAttach: (position) {
position.isScrollingNotifier.addListener(() {
// fires when scrolling starts or stops
});
},
onDetach: (position) { /* clean up position-scoped listeners */ },
);
One timing caveat matters: at attach time the scroll metrics are not available yet, because the Scrollable has not laid out its content. Check position.hasContentDimensions, or listen for a ScrollMetricsNotification, before reading maxScrollExtent.
jumpTo, animateTo, and choosing a curve
Both methods move the viewport; they differ in feel and in guard rails.
jumpTo(double value)is instantaneous and does not check whether the value is in range.animateTo(double offset, {required Duration duration, required Curve curve})animates to an absolute offset and returns aFuture<void>you can await.
void _scrollToTop() {
if (!_controller.hasClients) return;
if (_controller.offset == 0) return;
_controller.animateTo(
0,
duration: const Duration(milliseconds: 350),
curve: Curves.easeOutCubic,
);
}
Practical rules of thumb. Use Curves.easeOutCubic for revealing content above the fold, Curves.easeInOutCubic for deliberate repositioning, and a decelerating curve such as Curves.easeOut when the user just triggered the movement. Keep short jumps under about 300 ms; longer durations feel laggy on scroll-to-anchor actions. Await the future if a follow-up action depends on arrival:
Future<void> _jumpToEndAndFocus() async {
if (!_controller.hasClients) return;
await _controller.animateTo(
_controller.position.maxScrollExtent,
duration: const Duration(milliseconds: 500),
curve: Curves.easeOut,
);
_messageFocus.requestFocus();
}
Remember jumpTo skips range checks. Feeding it an out-of-range value is tolerated by the position, but you should clamp yourself so your UI and the real offset agree:
final position = _controller.position;
_controller.jumpTo(offset.clamp(position.minScrollExtent, position.maxScrollExtent));
Offset-driven UI versus scroll notifications
You have two standard ways to react to scrolling, and the API docs describe the tradeoff directly.
A ScrollController is a Listenable. It notifies when any attached ScrollPosition notifies, which gives you the position object itself but says nothing about who is scrolling. A NotificationListener<ScrollNotification> bubbles through the widget tree and carries the activity (ScrollUpdateNotification, UserScrollNotification, and friends) plus metrics, but not the position object.
NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification is ScrollUpdateNotification) {
final scrolled = notification.metrics.pixels > kToolbarHeight;
// update a local ValueNotifier or a cheap widget
}
return false; // false lets the notification keep bubbling
},
child: ListView.builder(/* ... */),
)
Pick per use case:
- Controller listener: you own the scrollable, you want the exact
ScrollPosition(extentAfter,axisDirection), or you need to drive programmatic scrolls. - Scroll notification: the scrollable belongs to a child widget or a third-party package, or you care about the activity type (user drag versus ballistic settling).
ScrollMetricsNotification: the values changed without scrolling - a window resize, a keyboard inset, or content that grew. Controller andScrollNotificationlisteners do not fire for that.ScrollNotificationObserver: the listener lives outside the scrollable’s subtree.Scaffold.appBarandScaffold.bodyare separate subtrees, so an app bar never sees body scroll notifications directly; this is how the Material scrolled-under effect is built.
Inside the scrollable itself you can skip the controller entirely: Scrollable.of(context) gives you the ScrollableState and its position.
Lazy loading with ListView.builder and extent thresholds
For long or infinite lists, use ListView.builder. The plain constructor builds every item up front; the builder constructor creates items as they scroll onto screen. Fixing the child extent makes the scrolling machinery cheaper still, via prototypeItem, itemExtent, or itemExtentBuilder for mixed sizes.
ListView.builder(
controller: _controller,
itemCount: _items.length + (_isLoading ? 1 : 0),
prototypeItem: const ListTile(title: Text('placeholder')),
itemBuilder: (context, index) {
if (index == _items.length) {
return const Center(child: Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(),
));
}
return ListTile(title: Text(_items[index]));
},
);
Then trigger the next page from the controller listener using an extent threshold instead of a magic offset:
bool _isLoading = false;
void _onScroll() {
if (_isLoading || !_controller.hasClients) return;
if (_controller.positions.length != 1) return;
final position = _controller.position;
// Fire when fewer than 600 logical pixels remain below the viewport.
if (position.extentAfter < 600) {
_loadMore();
}
}
Future<void> _loadMore() async {
setState(() => _isLoading = true);
try {
final page = await _api.fetchPage(_items.length ~/ 20);
setState(() => _items.addAll(page));
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
extentAfter is distance from the current offset to maxScrollExtent, so the threshold survives datasets of different length. Guard with _isLoading so a slow network call cannot fire ten overlapping requests while the user keeps flicking.
Nested and custom scrollables
Nesting a scrollable inside another is where performance goes to die if you reach for shrinkWrap: true everywhere - it forces both scrollables to lay out all their children. The Flutter scrolling overview points at the better answer: use slivers. Put the pieces in one CustomScrollView and let a single scroll position own the whole screen:
CustomScrollView(
controller: _controller,
slivers: const [
SliverAppBar(pinned: true, title: Text('Inbox'), expandedHeight: 180),
SliverList.builder(itemBuilder: /* ... */, itemCount: 40),
SliverFillRemaining(hasScrollBody: false, child: /* footer */),
],
)
For a genuinely independent inner list (a horizontal carousel inside a vertical feed), give the inner scrollable its own controller, or leave it on PrimaryScrollController so it does not share yours.
Avoid rebuild loops
A controller listener fires on every frame of a drag. Calling setState there rebuilds the entire subtree at 60+ Hz, which janks the scroll you are trying to observe. Narrow the blast radius:
final ValueNotifier<bool> _showToTop = ValueNotifier(false);
void _onScroll() {
if (!_controller.hasClients || _controller.positions.length != 1) return;
final show = _controller.position.extentAfter < 400;
if (show != _showToTop.value) _showToTop.value = show;
}
Then rebuild only the small widget that needs it:
ValueListenableBuilder<bool>(
valueListenable: _showToTop,
builder: (context, show, child) => show
? FloatingActionButton(
onPressed: () => _controller.animateTo(0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic),
child: const Icon(Icons.arrow_upward),
)
: const SizedBox.shrink(),
)
The equality check is the important line: it converts a per-frame notification into an edge-triggered state change. The same discipline applies to reading offset - always go through hasClients and a single-position check, because reading during a build before the scrollable has attached (or after a route transition disposed it) is how these crashes sneak in.
Key Takeaways
- Create the controller as a field on
State, add listeners ininitState, and alwaysdisposeit; never build one insidebuild. - One controller can drive many scrollables, but
offsetandpositionare single-position reads - checkhasClientsandpositions.lengthfirst. - Use
onAttach/onDetachfor position lifecycle, and remember metrics only exist after layout (hasContentDimensions,ScrollMetricsNotification). jumpTois instant and unclamped;animateTotakes a duration and curve - keep short UI moves around 300 ms with an ease-out curve.- Choose controller listeners for owned scrollables,
NotificationListenerfor activity and metrics,ScrollMetricsNotificationfor dimension changes, andScrollNotificationObserveracross subtrees. - Paginate with
ListView.builderplus anextentAfterthreshold and an in-flight guard, and never callsetStateon every scroll pixel.
References: