Skip to content
Blog

Tracking Scroll Physics and Metrics with NotificationListener in Flutter

Learn how to use NotificationListener<ScrollNotification> in Flutter to track scroll depth, detect user drag events, and trigger infinite scrolling pagination.

Published on • November 9, 2026

AI Assistant

Monitoring scroll position in Flutter is commonly handled by attaching a listener to a ScrollController. However, ScrollController requires creating, holding, and disposing an imperative object reference.

When you want to inspect scroll metrics declaratively—or intercept scroll events across descendant widgets without holding controller references—NotificationListener<ScrollNotification> offers a decoupled, message-passing mechanism.

How NotificationListener Works

In Flutter’s framework, descendant widgets dispatch Notification bubbles upwards through the widget tree. NotificationListener intercepts these notifications as they travel toward the root.

For scrollable widgets (ListView, GridView, CustomScrollView), the framework dispatches subclasses of ScrollNotification:

  • ScrollStartNotification: Fired when the user initiates a drag gesture or a scroll animation starts.
  • ScrollUpdateNotification: Fired repeatedly on every frame while the scroll position updates.
  • UserScrollNotification: Indicates changes in scroll direction (e.g. ScrollDirection.forward vs ScrollDirection.reverse).
  • ScrollEndNotification: Fired when scrolling comes to a complete halt.

Intercepting Scroll Events

import 'package:flutter/material.dart';

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

  @override
  State<InfiniteScrollList> createState() => _InfiniteScrollListState();
}

class _InfiniteScrollListState extends State<InfiniteScrollList> {
  final List<int> _items = List.generate(20, (i) => i);
  bool _isLoadingMore = false;

  void _loadMoreItems() async {
    if (_isLoadingMore) return;
    setState(() => _isLoadingMore = true);

    await Future.delayed(const Duration(milliseconds: 1500));

    setState(() {
      _items.addAll(List.generate(10, (i) => _items.length + i));
      _isLoadingMore = false;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('NotificationListener Scroll Demo')),
      body: NotificationListener<ScrollNotification>(
        onNotification: (ScrollNotification notification) {
          final metrics = notification.metrics;

          // Detect when scroll approaches 85% of total scroll extent
          if (metrics.pixels >= metrics.maxScrollExtent * 0.85) {
            _loadMoreItems();
          }

          // Return false to allow notification to bubble up further
          return false;
        },
        child: Column(
          children: [
            Expanded(
              child: ListView.builder(
                itemCount: _items.length,
                itemBuilder: (context, index) {
                  return ListTile(
                    leading: CircleAvatar(child: Text('${_items[index]}')),
                    title: Text('Data Item #${_items[index]}'),
                  );
                },
              ),
            ),
            if (_isLoadingMore)
              const Padding(
                padding: EdgeInsets.all(12.0),
                child: CircularProgressIndicator(),
              ),
          ],
        ),
      ),
    );
  }
}

Reading ScrollMetrics

The ScrollMetrics object passed with notification.metrics provides vital layout and scroll information:

  • metrics.pixels: Current scroll position in logical pixels.
  • metrics.maxScrollExtent: Total scrollable distance available.
  • metrics.minScrollExtent: Minimum scroll bound (typically 0.0).
  • metrics.viewportDimension: Height (or width) of the visible scroll window.
  • metrics.extentBefore: Distance scrolled past the start edge.
  • metrics.extentAfter: Distance remaining before reaching the end edge.
  • metrics.axisDirection: Axis direction (AxisDirection.down, right, etc.).

Hiding Floating Action Buttons on Scroll Down

By listening to UserScrollNotification, you can hide UI chrome (such as a FloatingActionButton or bottom navigation bar) when the user scrolls down, and reveal it when scrolling up:

NotificationListener<UserScrollNotification>(
  onNotification: (notification) {
    if (notification.direction == ScrollDirection.reverse) {
      // User is scrolling DOWN - hide FAB
      _setFabVisibility(false);
    } else if (notification.direction == ScrollDirection.forward) {
      // User is scrolling UP - show FAB
      _setFabVisibility(true);
    }
    return false;
  },
  child: ListView(/* ... */),
)

Takeaway: NotificationListener<ScrollNotification> provides decoupled, declarative access to scroll metrics, making infinite pagination and scroll-direction UI triggers easy to implement.