A sliver is a piece of a scrollable area that lays itself out from the current scroll offset instead of from a box size. CustomScrollView stacks several slivers in one scroll with one position, which a ListView (a single sliver list) cannot do.
A sliver receives SliverConstraints (scroll offset, how much of the viewport is left) and answers with a SliverGeometry, so each part of the page can decide how it behaves as the user scrolls.
Collapsing or pinned headers: SliverAppBar, SliverPersistentHeader and PinnedHeaderSliver exist only as slivers
Mixed content in one scroll: a header box, a grid and a list side by side (SliverToBoxAdapter, SliverGrid, SliverList), all lazy
Short content: SliverFillRemaining fills the rest of the screen for an empty state or a footer
**Why not Column + ListView**: nesting scrollables or using shrinkWrap throws laziness away; slivers keep one viewport
A plain list needs none of this: ListView is already a scroll view around a single sliver list
FlutterSenior
Slivers & Scrolling
CustomScrollView, SliverAppBar, ScrollController, ScrollPhysics, NestedScrollView, keep-alives
Questions closed
0 / 22
- 0Review
- 1Asked often
Questions
In debug mode it throws as the viewport builds its children: "A RenderViewport expected a child of type RenderSliver but received a child of type RenderPadding". Every entry in slivers must be a sliver, and Padding and ListView are box widgets.
**A box inside slivers**: wrap it in SliverToBoxAdapter; to pad slivers use SliverPadding
**ListView inside slivers**: replace it with SliverList.builder with the same itemCount and itemBuilder; a ListView is a whole scroll view, not a sliver
The mirror mistake: a SliverList placed in a Column fails with the opposite message, because a box parent expects a RenderBox
Rule of thumb: widgets with Sliver in the name go into slivers; everything else needs an adapter
Wrap the scrollable in RefreshIndicator and return a Future from onRefresh; the spinner stays until it completes. A list that fits on screen is not draggable unless it is primary or has AlwaysScrollableScrollPhysics, so nothing reaches the indicator.
Where the default goes away: on mobile a vertical list without a controller is primary and always scrollable; pass your own controller (common with pagination) or run on web/desktop and you must add the physics yourself
Await the real work: if onRefresh returns before the data arrives, the spinner disappears too early
Empty and error states: put them inside a scrollable (a ListView with one child or SliverFillRemaining), otherwise there is nothing to pull
iOS look: RefreshIndicator.adaptive, or CupertinoSliverRefreshControl as the first sliver of a CustomScrollView with bouncing physics
expandedHeight is the open size, the toolbar is the collapsed size, and the flags decide what happens in between: pinned keeps the toolbar on screen, floating brings the bar back on any upward scroll, snap finishes that motion on release.
**pinned**: the toolbar and bottom (often a TabBar) stay once the header has collapsed; without it the whole bar scrolls away
**floating: the bar reappears as soon as the user scrolls up, wherever the list is, not only at the top
snap**: only together with floating: true (otherwise an assert fires): on release the bar animates fully open or fully closed
**FlexibleSpaceBar**: fills the expanded area: a background (an image with CollapseMode.parallax) and a title that shrinks onto the toolbar
Height gotcha: expandedHeight already includes bottom, and the status-bar padding is added on top of it
Common mistake: a title on both SliverAppBar and FlexibleSpaceBar gives two titles, and only one of them animates
Material 3: SliverAppBar.medium and SliverAppBar.large give the standard collapsing title without a FlexibleSpaceBar
The delegate declares maxExtent and minExtent; the sliver reserves maxExtent of scroll and calls build with a shrinkOffset that is the scroll offset capped at maxExtent; the header is fully collapsed at maxExtent - minExtent. With pinned: true it then parks at minExtent.
**build(context, shrinkOffset, overlapsContent)**: (shrinkOffset / (maxExtent - minExtent)).clamp(0.0, 1.0) is your 0-to-1 animation value; the clamp matters because a pinned header keeps receiving larger shrinkOffset values; overlapsContent is true while rows slide under the pinned header, which is when a shadow belongs
Fill the space you are given: the child gets the current extent as a maximum height, not a forced size; a shorter child fails with "The layoutExtent exceeds the paintExtent", so return something that expands (Align, SizedBox.expand) and drive padding and font size from shrinkOffset
**shouldRebuild**: called when a new delegate instance arrives; compare the fields build reads. => true rebuilds on every parent rebuild, => false freezes the header on stale data
Simpler options since Flutter 3.24: PinnedHeaderSliver (pinned, no delegate) and SliverResizingHeader (sizes between two prototype widgets) cover most headers without a delegate class
shrinkWrap: true makes a list as tall as its content instead of filling the viewport, and to know that height it builds and lays out every child, so the list is no longer lazy.
It is the usual quick fix for "Vertical viewport was given unbounded height" inside a Column or another list. With ten rows it is harmless; with hundreds every rebuild pays for all of them. A Column of rows inside a SliverToBoxAdapter costs the same, because the viewport skips whole slivers, never rows inside one box.
A list under a header: one CustomScrollView: SliverToBoxAdapter for the header, SliverList.builder for the rows
Several lists in one scroll: one SliverList per section, grouped with SliverMainAxisGroup when a section has its own header
**A list that fills the rest of a Column**: Expanded(child: ListView.builder(...))
When it is fine: a short bounded list that must size to content, such as five options in a dialog; add NeverScrollableScrollPhysics if the parent scrolls
A ScrollController gives you the attached ScrollPosition: read offset or position.extentAfter in a listener and move with jumpTo or animateTo. In initState no scroll view has attached a position yet, so offset asserts.
Load-more: in the listener check position.extentAfter < 500 (pixels left below the viewport) and call a loader that ignores calls while a page is already in flight
**No setState in the listener**: it fires on every scroll frame; push the derived value into a ValueNotifier and rebuild only the widget that uses it
Before the first layout: use initialScrollOffset, or addPostFrameCallback plus a hasClients check
**animateTo returns a Future: it completes when the animation ends or the user grabs the list, not necessarily at the target
Ownership**: create it once in the State, dispose() it in dispose, one controller per scroll view
The listener calls onLoadMore on every scroll frame while the user is within 500 px of the end, so one gesture sends many requests for the same page. And the controller is created in build, so every rebuild makes a new one that is never disposed.
Guard the load: keep an isLoading flag (and a hasMore flag) in whoever owns the pages, and return early while a request is in flight
Create the controller once: a late final field or initState, with dispose() in dispose; ScrollController is a ChangeNotifier, and with leak tracking on (tests, DevTools) every undisposed instance is reported
Why the bug hides: Scrollable moves its existing ScrollPosition to the new controller, so the offset survives each rebuild and the list looks fine
What still breaks: anything else that kept the old controller, such as a back-to-top button, now holds one with no clients
setState only marks the list dirty; the new row is built and laid out in the next frame. maxScrollExtent read right after it is still the old end, so the list animates to where it already is and the new message stays one row below the fold.
Why it happens: build and layout run when the next frame is drawn, and only layout updates ScrollPosition.maxScrollExtent; everything in the same synchronous call sees the old geometry. The same trap catches Scrollable.ensureVisible on a row you just added
The fix: WidgetsBinding.instance.addPostFrameCallback runs after that frame's layout, so the extent already includes the new row; check mounted and hasClients, because the screen may be gone by then
Simpler for chats: ListView.builder(reverse: true) with the newest message at index 0: the bottom is always offset 0, so a new message appears in place with no extent to read and no frame timing
How to catch it: a widget test: send, pumpAndSettle, then expect position.pixels == position.maxScrollExtent or the new bubble's rect inside the viewport
A controller reports only the position it is attached to; a NotificationListener hears every scrollable below it, including ones you did not create. That is why the horizontal carousel's notifications reach the feed's listener; filter on depth == 0.
**depth: 0 is the nearest scrollable; a nested one arrives with a larger number
Return value**: true stops the notification from bubbling further, false lets ancestors see it
Useful types: ScrollStartNotification and ScrollEndNotification for drag start and end, ScrollUpdateNotification with metrics, UserScrollNotification for direction changes (hide-on-scroll buttons)
**notificationPredicate**: RefreshIndicator and Scrollbar accept depth 0 only; inside a NestedScrollView the inner list is depth 1, so pass (n) => n.depth == 1
When the listener is the better tool: you do not own the controller (a package widget, a NestedScrollView body), or you need the start and end of a gesture
A ScrollController listener runs only when the offset changes. On a tall screen the first 10 posts fit, maxScrollExtent is 0 and there is nothing to scroll, so _onScroll never runs and page 2 is never requested.
The fix: re-check after the frame's layout: addPostFrameCallback from initState (first frame) and from didUpdateWidget (every new page). Loading goes on until the content passes the viewport by 400 px, even if the list never becomes scrollable
Guard the loader: it must drop calls while a page is in flight and stop when there are no more pages (hasMore), otherwise a short last page loops; check mounted in the callback
Other fixes: ScrollMetricsNotification helps only while the metrics change (first layout, rotation, the page that makes the list overflow); it does not fire when the content is still shorter than the viewport. The other alternative is requesting from itemBuilder when the last rows are built, as infinite_scroll_pagination does: building follows layout, not scrolling
Other fixes: re-check in addPostFrameCallback after each page arrives, or request from itemBuilder when the last rows are built, as infinite_scroll_pagination does: building follows layout, not scrolling
How to catch it: a widget test with a tall tester.view.physicalSize that pumps the feed and expects a second request
ScrollPhysics decides how a scroll position reacts to a drag, to the edges and to a fling. The platform default comes from ScrollBehavior (bouncing on iOS and macOS, clamping elsewhere), and a physics you pass is applied on top of that default through applyTo.
**BouncingScrollPhysics: overscroll with a spring back; ClampingScrollPhysics stops at the edge (the stretch or glow comes from the behavior, not the physics)
NeverScrollableScrollPhysics: the user cannot drag it; for a list inside another scrollable or one moved only from code
AlwaysScrollableScrollPhysics: accepts a drag even when the content fits, which pull-to-refresh needs
Composition**: physics form a chain through parent, and each class overrides only what it changes: BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics())
App-wide: a custom ScrollBehavior in MaterialApp.scrollBehavior or a ScrollConfiguration, instead of physics on every list; it also sets dragDevices, so a mouse can drag lists on web and desktop
**primary**: marks the scroll view that uses the PrimaryScrollController, so an iOS status-bar tap scrolls it to the top
Everything within 250 logical pixels above and below the viewport is built and laid out but not painted: that is the default cacheExtent. With 96 px rows that is about three extra rows on each side.
Those rows are fully real: elements mounted, initState run, layout done; only painting is skipped. When a row leaves the cache region it is unmounted and its State disposed, so scrolling back and forth across that edge builds it again each time.
**So initState is the wrong place for a request: prefetching images 250 px ahead is useful, firing network calls for rows the user may never see is not; load data per page in the layer that owns the list
Tune with cacheExtent**: logical pixels per side; a larger value buys smoother scrolling for more build and layout work, and 0 makes rows appear only when needed, which tends to stutter
Fixed extents help more: itemExtent, prototypeItem or SliverFixedExtentList turn layout into arithmetic instead of measuring every row
You need the row's scroll offset, and a lazy list knows it only for rows it has laid out. With a fixed or computable row height the offset is arithmetic; otherwise use a list that can jump to an index, such as scrollable_positioned_list or super_sliver_list.
Fixed height: itemExtent: 72, then animateTo(index * 72); with itemExtentBuilder sum the extents before the index
**Scrollable.ensureVisible: works only for a row that is built right now (on screen or in the cache extent), so it suits "bring the selected chip into view", not long jumps
Unknown heights**: ItemScrollController.scrollTo(index: ...) from scrollable_positioned_list, or ListController.jumpToItem / animateToItem from super_sliver_list; both position the list around the target index, so the row's unknown offset is not needed
Clamp the target: to position.maxScrollExtent, or the last rows overshoot the end
newIndex is measured while the dragged row is still in the list, so a downward move must subtract one before inserting. And ValueKey(i) ties each element to a position instead of to a task, so state stays put while the data moves.
The index fix: if (newIndex > oldIndex) newIndex -= 1; before tasks.insert(newIndex, tasks.removeAt(oldIndex)); upward moves need no change
The key fix: key: ValueKey(tasks[i].id): a stable id from the data, unique in the list
Same frame: change the model synchronously inside onReorder; if the next build still shows the old order, the row flies back before the new state lands
Drag handles: ReorderableListView adds them by default (long press on mobile, a handle on desktop); set buildDefaultDragHandles: false and wrap your own in ReorderableDragStartListener. In a CustomScrollView use SliverReorderableList the same way
A lazy sliver unmounts rows that leave the viewport plus cache extent, disposing their State. The mixin asks the enclosing KeepAlive wrapper to keep that one element mounted instead, so a playing video or a half-typed field survives.
The contract: override wantKeepAlive, call super.build(context) in build, call updateKeepAlive() when the answer changes
Silent failure one: forgetting super.build(context): nothing registers and nothing fails at runtime; only the analyzer's must_call_super warning hints at it
Silent failure two: addAutomaticKeepAlives: false on the delegate removes the wrapper the mixin talks to
The cost: a kept row is not laid out or painted, but stays mounted: its state, decoded images, subscriptions and controllers stay in memory, and its tickers keep running
The rule: keep alive only what cannot be rebuilt cheaply, and only while it matters; anything that can live in the model (expanded flags, draft text, a nested list's offset) belongs there. Keep every row alive and you have rebuilt a non-lazy list
You need it when a collapsing header sits above a TabBarView or PageView whose pages scroll on their own. It runs two scroll positions, and the overlap a pinned header creates in the outer one does not reach the inner lists unless you pass it across.
Inside one CustomScrollView this is automatic: a pinned sliver reports its overlap and the next sliver reads it as constraints.overlap. A NestedScrollView has an outer viewport for the header and an inner one per page, so each page starts at its own offset 0, under the bar.
Absorb: wrap the pinned SliverAppBar in SliverOverlapAbsorber with NestedScrollView.sliverOverlapAbsorberHandleFor(context)
Inject: put a SliverOverlapInjector with the same handle first in every inner CustomScrollView; the Builder gives the lookup a context below the NestedScrollView
No own controller on inner lists: they join through the PrimaryScrollController; give one its own ScrollController and the header stops collapsing
Shared header position: all tabs share the outer position, so leaving a long tab for a short one can show a collapsed header over a list with nothing to scroll
Two mechanisms. PageStorageKey keeps the offset in an in-memory bucket that lives as long as the route; restorationId writes it into the platform's state-restoration data, so it survives the process being killed.
**How PageStorage works**: a ScrollPosition writes its offset into the bucket every time a scroll ends and reads it back when a new position is created, keyed by the PageStorageKeys above it
Unique keys: the same key on two tabs restores the same number to both
The bucket dies with the route: popping the screen, a hot restart or process death lose it
**restorationId goes on the scroll view**: ListView(restorationId: 'inbox'), and it does nothing without MaterialApp(restorationScopeId: ...)
It runs only when the system restarts the app: a background kill on Android, a relaunch on iOS; swiping the app away restores nothing. Test with "Don't keep activities" in Android developer options
The timing trap: if rows load asynchronously, the restored offset lands on an empty list and clamps to 0; restore how many pages were loaded too, or reserve the height with placeholders
A SliverList cannot know the height of a row it has not built, so maxScrollExtent is an estimate extrapolated from the rows laid out so far, and it changes whenever that sample changes.
The estimate is roughly: average height of built rows times the rows still to come, plus what is already laid out. If the first rows are unusually tall or short, the guess is off by thousands of pixels. As you scroll it moves, so the thumb resizes under your finger, and jumpTo(maxScrollExtent) lands on the current estimate; then more rows are built and the end is further away.
**itemExtent or SliverFixedExtentList**: one height for all rows: offsets are arithmetic and no row needs measuring; prototypeItem takes the height from one sample row
**itemExtentBuilder / SliverVariedExtentList**: a height per index, computed without building the widget, so the total is exact, the scrollbar is honest and jumpTo lands
This needs heights you can compute from data: line count, image aspect ratio; if not, packages such as super_sliver_list estimate better and correct as they go
**Not a fix: shrinkWrap: true**: exact extent by building all ten thousand rows in one frame
Put both halves in one CustomScrollView with a center key: slivers listed before the centre sliver are laid out at negative scroll offsets, so prepending history moves minScrollExtent instead of the content under the user's thumb.
A viewport has one centre sliver, and its leading edge is scroll offset zero. Slivers before it grow backwards, slivers after it grow forwards. So the history list comes first, with index 0 nearest the anchor and higher indices further in the past: a new page extends the negative side, and everything on screen keeps its offset.
**ListView(reverse: true) is enough when only history grows**: center is for when both ends grow
Offset 0 is no longer the top: minScrollExtent is negative, and jumpTo(0) means the boundary between history and new messages
**"At the bottom" is pixels >= maxScrollExtent - epsilon: auto-scroll on a new message only then, or you pull the reader out of the history
center must equal the key of a sliver in the list**: set on the sliver itself; a key that matches nothing asserts
Not with one shared ScrollController: it can hold several positions, but a drag moves only its own position, and reading offset asserts once two are attached. Either mirror two controllers, or make it one scrollable.
Two controllers mirroring each other: a listener on each calls jumpTo on the other behind a re-entrancy flag; use jumpTo, never animateTo, and clamp to the follower's own maxScrollExtent
What mirroring costs: each jumpTo cancels whatever the follower was doing and sends its own scroll notifications, so its scrollbar and listeners fire every frame, and overscroll on one half is not mirrored because the follower is clamped
One shared offset: LinkedScrollControllerGroup from linked_scroll_controller hands out controllers whose positions move together from one offset, with no echo and no re-entrancy flag
One viewport: TableView from two_dimensional_scrollables scrolls both axes in a single viewport with pinnedColumnCount, so alignment is layout, not synchronisation
Halves that must never disagree want one position or one viewport; lists that merely follow each other are fine mirrored.
performLayout must assign geometry; the viewport starts the next sliver after your layoutExtent, not after what you painted. If you only need to read SliverConstraints and choose existing slivers, SliverLayoutBuilder is enough.
The three extents
**scrollExtent**: how much scroll the sliver consumes; the sum over all slivers is maxScrollExtent
**paintExtent**: what is drawn this frame; it may not exceed constraints.remainingPaintExtent or maxPaintExtent, or an assert fires
**layoutExtent**: where the next sliver starts; defaults to paintExtent and may not be larger. A pinned header that has collapsed reports 0 here while still painting, so content scrolls under it
Helpers and traps
**calculatePaintOffset and calculateCacheOffset**: clamp against scrollOffset and the remaining extent for you; hand-written min/max is where the asserts come from
Children: if you position a child yourself you also owe childMainAxisPosition and hitTestChildren, or taps land nowhere; RenderSliverSingleBoxAdapter covers one box child
**scrollOffsetCorrection: returning it makes the viewport shift the offset and lay out again; keep it conditional or layout loops
When not to write one
SliverLayoutBuilder**: when you only need to read SliverConstraints and pick existing slivers
Headers: PinnedHeaderSliver, SliverResizingHeader or SliverPersistentHeader cover pinning and shrinking; a RenderSliver is for genuinely new layout