In Flutter, when should you observe scrolling with a ScrollController listener instead of a NotificationListener<ScrollNotification>?
answer
- controller fires on every offset change
- notifications bubble up the tree
- start, update, overscroll, end, user
- depth 0 is the nearest scrollable
- return true stops bubbling
basics
~20 sA ScrollController listener fires on every offset change of a list you own and reads offset directly. NotificationListener<ScrollNotification> catches typed start, update, overscroll and end events bubbling from any scrollable below it, with metrics, no controller needed.
solid answer
~40 s`ScrollController.addListener` runs on every change of the attached position — user drags, flings and programmatic `jumpTo` or `animateTo` alike — and gives you `offset` and `position`, so it suits logic tied to one list you already control, like showing a back-to-top button past 600 pixels. `NotificationListener<ScrollNotification>` sits above any scrollable and receives notifications as they **bubble up**: `ScrollStartNotification`, `ScrollUpdateNotification`, `OverscrollNotification`, `ScrollEndNotification` and `UserScrollNotification`, each carrying `metrics` and a `depth`. It needs no controller, tells you *phases* such as 'scroll ended', and can listen to lists you don't own; filter on `depth == 0` so nested scrollables don't leak in, and return `true` only to stop bubbling. Either way, update state only when a threshold is crossed.
code
dart · 49 linesimport 'package:flutter/material.dart';
class _ForumThreadPageState extends State<ForumThreadPage> {
final ScrollController _controller = ScrollController();
final ValueNotifier<bool> _showBackToTop = ValueNotifier<bool>(false);
@override
void initState() {
super.initState();
_controller.addListener(_onScroll);
}
void _onScroll() {
// ValueNotifier only notifies when the value actually changes.
_showBackToTop.value = _controller.offset > 600;
}
@override
void dispose() {
_controller.removeListener(_onScroll);
_controller.dispose();
_showBackToTop.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: ValueListenableBuilder<bool>(
valueListenable: _showBackToTop,
builder: (context, show, _) => show
? FloatingActionButton(
onPressed: () => _controller.animateTo(
0,
duration: const Duration(milliseconds: 400),
curve: Curves.easeOut,
),
child: const Icon(Icons.arrow_upward),
)
: const SizedBox.shrink(),
),
body: ListView.builder(
controller: _controller,
itemCount: widget.posts.length,
itemBuilder: (context, index) => ListTile(title: Text(widget.posts[index])),
),
);
}
}go deeper
Remember both tools exist: addListener on a ScrollController and NotificationListener around the list.
Explain the five notification types, depth, bubbling and the return value, and when each tool fits.
Keep scroll observers cheap with derived values and ValueNotifier, and filter nested scrollables by depth or axis.
Decide where scroll-driven UI logic lives so features do not stack competing listeners on the same list.
## Two ways to observe a scroll Flutter offers two mechanisms, and interviewers want to hear which one fits which job. - **`ScrollController` listener** — a `ScrollController` is a `ChangeNotifier`. `addListener` registers a callback that runs whenever the attached `ScrollPosition` changes. Inside it you read `controller.offset` or `controller.position` (with `pixels`, `maxScrollExtent`, `extentBefore`, `extentAfter`, `userScrollDirection`). - **`NotificationListener<ScrollNotification>`** — a `Scrollable` dispatches **notifications** that bubble up the element tree. A `NotificationListener` placed anywhere above it receives those of the requested type, with a `metrics` snapshot and a `depth`. ## The notification types | Notification | When it is dispatched | |---|---| | `ScrollStartNotification` | a scroll activity begins (drag, fling, animation) | | `ScrollUpdateNotification` | the position changed; includes `scrollDelta` and, for drags, `dragDetails` | | `OverscrollNotification` | the physics rejected movement past an edge, as clamping physics do | | `ScrollEndNotification` | the scroll came to rest | | `UserScrollNotification` | the user's scroll direction changed (forward, reverse, idle) | The generic parameter narrows what you receive: `NotificationListener<ScrollEndNotification>` sees only end events. Because bouncing physics let the position go past the edge instead of rejecting it, an iOS-style list reports its overscroll as `ScrollUpdateNotification`s with an out-of-range position rather than `OverscrollNotification`s. ## Depth and bubbling - `depth` is 0 for the nearest scrollable and grows by one for each scrollable in between. A horizontal carousel inside a vertical feed sends notifications through the same listener, so check `notification.depth == 0` — or the axis in `metrics` — before reacting. - `onNotification` returns a `bool`. Returning **`true`** cancels further bubbling, hiding the event from ancestors such as a `Scrollbar` or a `RefreshIndicator` that listen the same way. Return `false` unless you mean to swallow it. ## Choosing between them | Need | Better fit | |---|---| | Show a back-to-top button past an offset in a list you own | controller listener | | React when scrolling **stops** (save position, load images, log analytics) | `ScrollEndNotification` | | Observe a scrollable built by someone else's widget | `NotificationListener` | | Detect the user changing scroll direction to hide a bottom bar | `UserScrollNotification` | | Drive the list as well as read it | controller — notifications are read-only | ## Doing it cheaply Both mechanisms fire on nearly every frame of a fling. Calling `setState` on each event rebuilds the whole screen at scroll rate. The usual pattern: 1. Compute a **derived value** (`offset > 600`). 2. Store it in a `ValueNotifier<bool>`; its setter ignores an equal value, so listeners run only when the threshold is crossed. 3. Rebuild just the button with a `ValueListenableBuilder`. ## Lifecycle A controller listener must be removed (or the controller disposed) with the `State`, or it keeps a reference to a dead `State`. A `NotificationListener` is a widget, so it goes away with the subtree and needs no cleanup — one reason it is preferred when you do not otherwise need a controller.
- What does returning true from onNotification do, and why is it risky?It stops the notification from bubbling further up the tree. Ancestors that listen the same way — a `Scrollbar`, a `RefreshIndicator`, another `NotificationListener` — then never see it, so a scrollbar may stop fading in. Return `true` only when you deliberately consume the event.
- A horizontal carousel sits inside a vertical thread; why does the back-to-top logic flicker?Scroll notifications from the carousel bubble through the same listener, with the carousel's horizontal `metrics`. Without a filter the code reads the carousel's `pixels` as the thread's offset. Check `notification.depth == 0` or `metrics.axis` so only the nearest vertical scrollable counts.
saying these in an interview costs you the question
- A controller listener fires only while the user is dragging.
- NotificationListener requires the list to have a ScrollController.
- Returning true from onNotification is the normal default.
- Calling setState on every scroll event is fine for a button.
- Nested scrollables never reach an outer NotificationListener.