skip to content

In Flutter, what is PrimaryScrollController, and why does tapping the iOS status bar scroll some lists to the top but not others?

level: middleimportance: nice to knowfreq 25%

answer

  1. each ModalRoute injects one
  2. vertical views on mobile inherit it
  3. own controller opts out
  4. Scaffold animates it to zero
  5. two primaries on one route clash

basics

~20 s

PrimaryScrollController is an inherited ScrollController each route provides. Vertical scroll views on Android, iOS and Fuchsia that get no controller of their own attach to it, and Scaffold scrolls it to top on an iOS status-bar tap. A list with its own controller is skipped.

solid answer

~40 s

`PrimaryScrollController` is an inherited widget holding a `ScrollController`. Every `ModalRoute` inserts one, configured to be inherited automatically on the mobile platforms (Android, iOS, Fuchsia) by scroll views in the vertical direction. A `ScrollView` whose `primary` is `null` and which has no `controller` attaches to it; `primary: false` opts out, and `primary: true` cannot be combined with an explicit controller. On iOS, `Scaffold` handles a status-bar tap by animating the primary controller to 0. So a list that received its own `ScrollController` — to show a back-to-top button, say — silently loses the status-bar gesture. Two primary lists on one route both attach, so reading the primary controller's `offset` asserts. It also feeds default keyboard `ScrollAction`s and the `Scrollbar` fallback.

code

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

class LineupFeed extends StatelessWidget {
  const LineupFeed({super.key, required this.acts, required this.news});

  final List<String> acts;
  final List<String> news;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Lineup')),
      body: Row(
        children: [
          Expanded(
            // Inherits the route's PrimaryScrollController on mobile,
            // so an iOS status-bar tap scrolls it to the top.
            child: ListView.builder(
              itemCount: acts.length,
              itemBuilder: (context, i) => ListTile(title: Text(acts[i])),
            ),
          ),
          SizedBox(
            width: 200,
            // Opt out so the two lists do not share one controller.
            child: ListView.builder(
              primary: false,
              itemCount: news.length,
              itemBuilder: (context, i) => ListTile(title: Text(news[i])),
            ),
          ),
        ],
      ),
    );
  }
}

go deeper

for a junior

Know that each screen has a primary scroll controller and that iOS scrolls it to the top on a status-bar tap.

for a middle

Explain which scroll views inherit it by platform and direction, and what primary: true, false and null do.

for a senior

Trace a lost status-bar gesture or a multiple-attach assertion to primary inheritance and fix it without new regressions.

for a principal

Set a convention for which list is primary on multi-pane screens so platform gestures behave predictably.

## What it is `PrimaryScrollController` is an **inherited widget** that makes one `ScrollController` available to a subtree. Scroll views below it can attach to that controller implicitly, without anyone passing it down. That shared controller gives the framework a way to find *the* main scroll view of a screen. ## Where it comes from Every `ModalRoute` — every page pushed with `Navigator` or `go_router` — wraps its content in a `PrimaryScrollController`. Its defaults: - `automaticallyInheritForPlatforms`: Android, iOS and Fuchsia (the mobile set); - `scrollDirection`: `Axis.vertical`. On desktop and the web those defaults mean no automatic inheritance; a scroll view there attaches only with `primary: true`. ## Which scroll views attach | `ScrollView` settings | Result | |---|---| | `primary: null`, no `controller`, vertical, mobile platform | inherits the primary controller | | `primary: null`, no `controller`, horizontal | does not inherit | | `primary: false` | never inherits | | `primary: true` | uses the primary controller on every platform; an explicit controller is not allowed | | explicit `controller:` | uses that controller; not primary | ## What it powers 1. **iOS status-bar tap.** `Scaffold` (when `primary` is true) handles a status-bar tap by looking up the primary controller and, if it has clients, animating it to offset 0 over one second with an ease-out curve. 2. **Default keyboard scrolling.** Unhandled `ScrollAction`s, such as Page Up and Page Down shortcuts, go to the primary scroll view. 3. **Scrollbar fallback.** A `Scrollbar` with no controller uses the primary one on mobile for vertical scroll views. 4. **NestedScrollView.** Its `body` gets a coordinated `PrimaryScrollController`, which is why inner lists there must not take their own controller. ## Why the status-bar tap stops working The usual story: a feature adds a `ScrollController` to the main list to drive a back-to-top button. The list is no longer primary, the status-bar tap finds a primary controller with **no clients**, and nothing happens. Two fixes: - Use the primary controller instead of a new one: read it with `PrimaryScrollController.of(context)` below the route and attach listeners to it. - Or keep your own controller and accept that the status-bar gesture needs to be wired manually. ## Two primary lists on one route If two vertical lists on the same route both inherit, both attach to the same controller. `jumpTo` and `animateTo` move both, so the status-bar tap scrolls both lists; reading `offset` asserts that the controller is attached to multiple scroll views. Mark the secondary list `primary: false`, or give it its own controller. ## Overriding it - Wrap a subtree in `PrimaryScrollController(controller: myController, child: ...)` to make your controller the primary one there. - `PrimaryScrollController.none(child: ...)` removes inheritance for a subtree — useful around a secondary pane. ## Summary - One per route, inherited only by vertical views on mobile by default. - An explicit controller opts a list out, and with it the iOS status-bar gesture. - Two inheriting lists on one route share a controller, which breaks single-position reads.

  • How can a list keep the iOS status-bar gesture and still drive a back-to-top button?
    Do not give it a new `ScrollController`. Leave it primary and, from a widget below the route, read `PrimaryScrollController.of(context)` to add the listener and call `animateTo`. Alternatively wrap the subtree in `PrimaryScrollController(controller: yourController, ...)` so your controller becomes the primary one.
  • Why does a vertical list on macOS not scroll to top from the menu bar the same way?
    The route's `PrimaryScrollController` is inherited automatically only on Android, iOS and Fuchsia, and the status-bar tap is an iOS behaviour handled by `Scaffold`. On desktop a scroll view attaches to the primary controller only with `primary: true`, and any scroll-to-top gesture must be implemented explicitly.

saying these in an interview costs you the question

  • Every scroll view on every platform inherits PrimaryScrollController.
  • A list with its own controller still reacts to the status-bar tap.
  • Horizontal lists inherit the primary controller by default.
  • primary: true can be combined with an explicit controller.
  • Two inheriting lists on a route each get separate controllers.