In Flutter, what problem does NestedScrollView solve, and why does it need SliverOverlapAbsorber and SliverOverlapInjector?
answer
- outer header, inner tab lists
- headerSliverBuilder plus body
- inner lists use the PrimaryScrollController
- absorber in header, injector in body
- floatHeaderSlivers for floating bars
basics
~20 sNestedScrollView 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 sWith 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 linesimport '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
Know NestedScrollView is for a collapsing header above tab bodies that each scroll.
Explain the coordinated outer and inner controllers, the PrimaryScrollController rule and the absorber-injector pair.
Debug hidden first rows, headers that stop collapsing and non-floating bars, and know when a plain CustomScrollView is the better choice.
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.