skip to content

In Flutter, when should you observe scrolling with a ScrollController listener instead of a NotificationListener<ScrollNotification>?

level: middleimportance: should knowfreq 50%

answer

  1. controller fires on every offset change
  2. notifications bubble up the tree
  3. start, update, overscroll, end, user
  4. depth 0 is the nearest scrollable
  5. return true stops bubbling

basics

~20 s

A 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 lines
dart
import '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

for a junior

Remember both tools exist: addListener on a ScrollController and NotificationListener around the list.

for a middle

Explain the five notification types, depth, bubbling and the return value, and when each tool fits.

for a senior

Keep scroll observers cheap with derived values and ValueNotifier, and filter nested scrollables by depth or axis.

for a principal

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.