A Flutter screen asserts 'ScrollController attached to multiple scroll views' or 'not attached to any scroll views'; what causes each, and how do you fix it?
answer
- position assumes exactly one client
- jumpTo loops over positions
- one controller per scroll view
- attach happens at build, not init
- Scrollbar must share the controller
basics
~20 s'Not attached to any' means offset, position, jumpTo or animateTo ran before a scroll view built or after it was disposed. 'Attached to multiple' means one controller serves two scroll views and offset or position was read. Give each view its own controller and guard with hasClients.
solid answer
~50 sA `ScrollController` keeps a list of attached `ScrollPosition`s. `position` and `offset` assert that the list has **exactly one** entry, which gives the two messages. *Not attached to any* comes from touching the controller too early (in `initState`, or during the first frame) or too late (a timer after the route was popped); fix it with `hasClients` checks, `initialScrollOffset`, or a post-frame callback. *Attached to multiple* comes from sharing one controller between two scroll views — two tabs, a list and its duplicate in a `Row` — and then reading `offset`; `jumpTo` and `animateTo` loop over all positions, so the bug surfaces only on reads. Fix it with one controller per scroll view, or iterate `positions` deliberately. The same mismatch appears as `The Scrollbar's ScrollController has no ScrollPosition attached.` when a `Scrollbar` and its list use different controllers.
code
dart · 49 linesimport 'package:flutter/material.dart';
class _ThreadTabsState extends State<ThreadTabs> {
// One controller per scroll view, never one shared by both tabs.
final ScrollController _repliesController = ScrollController();
final ScrollController _mediaController = ScrollController();
@override
void initState() {
super.initState();
// Too early for jumpTo here: nothing is attached yet.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_repliesController.hasClients) return;
_repliesController.jumpTo(widget.lastReadOffset);
});
}
@override
void dispose() {
_repliesController.dispose();
_mediaController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Scrollbar(
controller: _repliesController,
child: ListView.builder(
controller: _repliesController,
itemCount: widget.replies.length,
itemBuilder: (context, i) => ListTile(title: Text(widget.replies[i])),
),
),
),
Expanded(
child: ListView.builder(
controller: _mediaController,
itemCount: widget.media.length,
itemBuilder: (context, i) => ListTile(title: Text(widget.media[i])),
),
),
],
);
}
}go deeper
Know that a controller must be attached before use, and that hasClients tells you whether it is.
Explain position versus positions, which calls assert which condition, and the Scrollbar controller rule.
Trace each assertion to its lifecycle or sharing cause and fix it structurally, not by wrapping it in try-catch.
Set ownership rules for controllers so they are never stored in app-wide state and shared across screens.
## How attachment works When a `Scrollable` builds, it creates a **`ScrollPosition`** and calls `attach` on its controller; when it is disposed or switches controllers, it calls `detach`. The controller therefore holds a *collection*, `positions`, and a convenience getter `position` that asserts: - `ScrollController not attached to any scroll views.` — the collection is empty; - `ScrollController attached to multiple scroll views.` — it holds more than one. `offset` is just `position.pixels`, so it raises the same assertions. `hasClients` is `positions.isNotEmpty`. ## Which calls check what | Member | Empty `positions` | Several `positions` | |---|---|---| | `hasClients` | returns false | returns true | | `position`, `offset` | asserts 'not attached' | asserts 'attached to multiple' | | `jumpTo`, `animateTo` | asserts 'not attached' | moves **every** attached position | | `positions` | empty iterable | all of them | That table explains why a shared controller can look fine for weeks: the back-to-top button calls `animateTo`, which happily scrolls both lists, until someone adds a listener that reads `offset`. ## Causes of 'not attached to any scroll views' 1. **Too early.** `jumpTo` or `offset` in `initState` or `didChangeDependencies`, before the first build has attached a position. 2. **Too late.** A `Timer`, stream subscription or awaited future calls the controller after the route was popped and the list detached. 3. **Never attached.** The controller was never passed to any scroll view, or the scroll view is behind a condition that is currently false. 4. **Conditional UI.** A loading spinner replaces the list; the controller is detached while the spinner shows. Fixes: guard with `if (_controller.hasClients)`; use `ScrollController(initialScrollOffset: ...)` for a starting position; defer one-off jumps with `WidgetsBinding.instance.addPostFrameCallback`; cancel timers and subscriptions in `dispose`. `onAttach` and `onDetach` callbacks exist for code that must react to attachment itself, though the metrics such as `maxScrollExtent` are not ready at attach time. ## Causes of 'attached to multiple scroll views' - One controller passed to the lists in two tabs that are alive at once. - A responsive layout that shows the same list twice (a master list and a mini-map). - A controller stored in a provider or singleton and used by every screen that shows a thread. The fix is almost always **one controller per scroll view**. If synchronised scrolling of two lists is the actual goal, iterate `positions` explicitly or keep two controllers and mirror one to the other in a listener, guarding against feedback loops. ## The Scrollbar variant A `Scrollbar` paints from a `ScrollPosition`. Given a `controller`, it must be the same one the scroll view uses, or it throws `The Scrollbar's ScrollController has no ScrollPosition attached.` Without a controller it falls back to the `PrimaryScrollController`, which only a primary vertical scroll view attaches to. On Linux, macOS and Windows, `MaterialScrollBehavior` already adds a `Scrollbar` to vertical scrollables using the scroll view's own controller; wrapping another one produces two bars, which `scrollBehavior.copyWith(scrollbars: false)` on that subtree avoids. `thumbVisibility` and `trackVisibility` keep the thumb and track shown, and `interactive` enables dragging the thumb. ## Disposal errors Calling any method after `dispose` raises `A ScrollController was used after being disposed.` It usually means an async callback outlived the `State`: check `mounted` and `hasClients` after every `await`.
- Why did a shared controller only start failing when someone added a scroll listener?`jumpTo` and `animateTo` loop over every attached position, so a shared controller just scrolls both lists. The listener reads `offset`, which goes through `position`, and that getter asserts a single client — so the first read exposes the sharing.
- When is onAttach useful, and what can you not read inside it?`onAttach` runs when a `ScrollPosition` attaches, for example to add a listener to `position.isScrollingNotifier`. At that moment layout has not happened, so `maxScrollExtent` and other metrics are not available; check `hasContentDimensions` or wait for a `ScrollMetricsNotification`.
- Why do desktop builds sometimes show two scrollbars on one list?On Linux, macOS and Windows `MaterialScrollBehavior.buildScrollbar` already wraps vertical scrollables in a `Scrollbar`. Adding your own around the list doubles it. Either rely on the automatic one or turn it off for that subtree with `ScrollConfiguration` and `copyWith(scrollbars: false)`.
saying these in an interview costs you the question
- jumpTo throws as soon as a controller has two scroll views.
- Sharing one ScrollController between tabs is the intended pattern.
- Calling jumpTo in initState is safe if the list is in build.
- A Scrollbar can use any controller, not the list's own.
- onAttach is the right place to read maxScrollExtent.