skip to content

In Flutter, what problem does NestedScrollView solve, and why does it need SliverOverlapAbsorber and SliverOverlapInjector?

level: middleimportance: should knowfreq 45%

answer

  1. outer header, inner tab lists
  2. headerSliverBuilder plus body
  3. inner lists use the PrimaryScrollController
  4. absorber in header, injector in body
  5. floatHeaderSlivers for floating bars

basics

~20 s

NestedScrollView links an outer scroll view holding a collapsing header to inner scroll views, typically lists inside a TabBarView, so they scroll as one. The absorber records the pinned header's overlap and the injector pads each inner list so its first rows are not hidden.

solid answer

~40 s

With a `TabBarView` of lists under a collapsing `SliverAppBar`, a single `CustomScrollView` cannot work: each tab's list has its own scroll position, and flinging it would not expand the header. `NestedScrollView` builds an outer scroll view from `headerSliverBuilder(context, innerBoxIsScrolled)` and gives the `body` a `PrimaryScrollController` that it coordinates with the outer one; inner lists must use that, so they must **not** receive their own `ScrollController`. Because the header and the inner lists are separate viewports, a pinned header would overlap the top of each inner list. Wrapping the header in `SliverOverlapAbsorber(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context))` records that overlap, and a `SliverOverlapInjector` with the same handle, first in each inner `CustomScrollView`, adds matching space. Floating headers also need `floatHeaderSlivers: true`.

code

dart · 49 lines
dart
import 'package:flutter/material.dart';

class FestivalDaysPage extends StatelessWidget {
  const FestivalDaysPage({super.key, required this.days});

  final Map<String, List<String>> days; // e.g. {'Friday': [...], ...}

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: days.length,
      child: Scaffold(
        body: NestedScrollView(
          headerSliverBuilder: (context, innerBoxIsScrolled) => [
            SliverOverlapAbsorber(
              handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
              sliver: SliverAppBar(
                pinned: true,
                expandedHeight: 220,
                forceElevated: innerBoxIsScrolled,
                flexibleSpace: const FlexibleSpaceBar(title: Text('Lineup')),
                bottom: TabBar(tabs: [for (final day in days.keys) Tab(text: day)]),
              ),
            ),
          ],
          body: TabBarView(
            children: [
              for (final MapEntry(key: day, value: acts) in days.entries)
                Builder(
                  builder: (context) => CustomScrollView(
                    key: PageStorageKey<String>(day),
                    slivers: [
                      SliverOverlapInjector(
                        handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
                      ),
                      SliverList.builder(
                        itemCount: acts.length,
                        itemBuilder: (context, i) => ListTile(title: Text(acts[i])),
                      ),
                    ],
                  ),
                ),
            ],
          ),
        ),
      ),
    );
  }
}

go deeper

for a junior

Know NestedScrollView is for a collapsing header above tab bodies that each scroll.

for a middle

Explain the coordinated outer and inner controllers, the PrimaryScrollController rule and the absorber-injector pair.

for a senior

Debug hidden first rows, headers that stop collapsing and non-floating bars, and know when a plain CustomScrollView is the better choice.

for a principal

Weigh the complexity of nested scroll coordination against a simpler single-scroll design with section jumps.

## The problem A festival app shows a collapsing header with the festival poster, a pinned tab row for *Friday*, *Saturday* and *Sunday*, and below it a `TabBarView` where each day is its own vertical list you can swipe between. In a single `CustomScrollView` that cannot work. The `TabBarView` is a horizontal page view; each page's list is a **separate** scroll view with its own position. Flinging Saturday's list to the top would not expand the collapsed header, because the header belongs to a different scrollable. ## What NestedScrollView does `NestedScrollView` creates **coordinated controllers**: one for an outer scroll view built from `headerSliverBuilder`, and one handed to the `body` as a `PrimaryScrollController`. A drag is split between them: scrolling down first collapses the header, then scrolls the inner list; scrolling up does the reverse. To the user it feels like one scroll. Key parameters: - `headerSliverBuilder(BuildContext context, bool innerBoxIsScrolled)` — returns the header slivers; `innerBoxIsScrolled` is handy for `SliverAppBar.forceElevated`. - `body` — usually a `TabBarView`. - `floatHeaderSlivers` (default `false`) — required for a **floating** `SliverAppBar` to float in over the body, because a floating bar watches its own scrollable's offset and the outer view does not see inner scrolling otherwise. ## The rule for inner lists The body is built with a `PrimaryScrollController` that belongs to the coordinator. Any inner list meant to scroll with the header must **not** get an explicit `ScrollController`; it should pick up the primary one. Passing your own controller disconnects that list from the header. ## Why the overlap widgets exist A pinned or snapping header occupies space at the top of the screen **even after** the outer view has scrolled as far as it can. In a single `CustomScrollView`, the viewport tells later slivers about that via `SliverConstraints.overlap`. Across two viewports nothing carries it, so the top rows of each inner list would sit under the header. | Widget | Where | Job | |---|---|---| | `SliverOverlapAbsorber` | wraps the header sliver | converts the header's overlap into a value stored on a handle | | `SliverOverlapAbsorberHandle` | from `NestedScrollView.sliverOverlapAbsorberHandleFor(context)` | shared object carrying the absorbed overlap | | `SliverOverlapInjector` | first sliver in each inner `CustomScrollView` | reads the handle and inserts that much space | The docs are explicit that snapping headers need this pair; without it the inner list can end up under the `SliverAppBar` even when it thinks it has not scrolled. ## Wiring it up 1. In `headerSliverBuilder`, return `SliverOverlapAbsorber(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), sliver: SliverAppBar(...))`. 2. In each tab, build a `CustomScrollView` whose first sliver is `SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context))`, followed by the `SliverList`. 3. Get that `context` from a `Builder` inside the tab, so the lookup finds the enclosing `NestedScrollView`. 4. Give each tab's scroll view a distinct `PageStorageKey` so its position survives swiping away and back. ## Common failures - **Inner lists with their own controller** — the header stops collapsing when those lists scroll. - **Injector missing** — the first rows hide under the pinned tab row. - **Floating bar does not float** — `floatHeaderSlivers` left `false`. - **Mixing it with a single list** — if there are no swipeable tab bodies, a plain `CustomScrollView` is simpler and cheaper.

  • Why must inner lists in a NestedScrollView body not get their own ScrollController?
    The body is built with a `PrimaryScrollController` owned by the nested-scroll coordinator, which is how inner scrolling is linked to the header. A list with an explicit controller ignores it, so scrolling that list no longer collapses or expands the header.
  • What does floatHeaderSlivers change?
    It defaults to `false`. When `true`, the coordinator applies a reverse drag to the header slivers first, so a floating `SliverAppBar` floats in over the body. Without it the floating bar only sees the outer view's offset and never reappears mid-list.

saying these in an interview costs you the question

  • A single CustomScrollView handles swipeable tabs that each scroll.
  • Each inner tab list should get its own ScrollController.
  • SliverOverlapInjector goes in the header next to the app bar.
  • Floating app bars float in a NestedScrollView without extra flags.
  • The overlap widgets only matter for Material 2 app bars.