Slivers & Scrolling
CustomScrollView, SliverAppBar, ScrollController, ScrollPhysics, NestedScrollView, keep-alives
- 01
Your custom sliver paints in the right place, but the sliver after it starts too low and never catches up — which number did you get wrong?
MediumlayoutExtent— the viewport advances the next sliver by that, not by the number of pixels you painted.// Reserves 200px of scroll but pins its last 56px: // the three extents deliberately disagree. class RenderPinnedBanner extends RenderSliver { static const double _full = 200; static const double _pinned = 56; … - 02
You wrapped a 300-row Column in a SliverToBoxAdapter and the first frame now takes 400 ms — what did that buy the framework, and when is SliverList the only answer?
MediumA
SliverToBoxAdapteris one box: its child is built, laid out and painted as a single unit, so nothing inside it is ever lazy.// ❌ One box: 300 rows built, laid out and painted as a single unit. CustomScrollView(slivers: [ SliverToBoxAdapter( child: Column(children: [for (final o in orders) OrderTile(order: o)]), ), ]); … - 03
A SliverAppBar with snap: true asserts at construction, and the 240 px header the designer asked for comes out taller — what are pinned, floating, snap and expandedHeight doing?
Mediumsnapmeans nothing withoutfloating: true— the assert is literal — andexpandedHeightis measured below the status bar, whose padding the delegate adds on top of it.// A header that collapses to a pinned toolbar with tabs underneath. CustomScrollView( slivers: [ SliverAppBar( pinned: true, // toolbar + TabBar stay on screen floating: true, // required by snap … - 04
A header that fades as the user scrolls rebuilds the whole page every frame, and reading controller.offset in initState throws — how do you fix both?
MediumLet something narrow listen to the controller instead of calling
setStatefrom it, and never touch a position before a scrollable has attached one.class _FeedHeaderState extends State<FeedHeader> { final _scroll = ScrollController(); final _collapsed = ValueNotifier<bool>(false); // derived: flips twice, not 800x @override void initState() { … - 05
Your feed's load-more fires while the user swipes a horizontal carousel inside one of the rows — why does that happen, and what is notificationPredicate for?
MediumScrollNotifications bubble up the element tree from every scrollable below you, so the carousel's updates reach the feed's listener;depthis what tells them apart.class Feed extends StatelessWidget { const Feed({super.key, required this.posts, required this.onLoadMore}); final List<Post> posts; final VoidCallback onLoadMore; bool _onScroll(ScrollNotification n) { … - 06
Setting physics: BouncingScrollPhysics() on Android gave you the iOS bounce and a new bug where the list jumps when a row above it resizes — what happened?
MediumYou replaced the whole physics chain, and the piece you dropped was
RangeMaintainingScrollPhysics, which is what holds the offset steady when content above the viewport changes size.class SnapPhysics extends ScrollPhysics { const SnapPhysics({required this.itemExtent, super.parent}); final double itemExtent; // Required so the chain can be rebuilt when the physics is applied. @override … - 07
How many rows ahead of the visible window does a ListView.builder actually build, and what does that mean for a row that fires a request in initState?
MediumEverything within 250 logical pixels of each edge of the viewport —
RenderAbstractViewport.defaultCacheExtent— is built and laid out, and simply never painted.// Default: RenderAbstractViewport.defaultCacheExtent == 250 logical pixels, // applied ABOVE and BELOW the viewport. ListView.builder( itemExtent: 96, // 250 / 96 -> about 3 extra rows on each side cacheExtent: 250, // the default, spelled out; 0 disables the prefetch itemCount: posts.length, … - 08
A row with a playing video restarts from zero every time it scrolls off screen and back — what did you lose, and what does keeping it alive cost you?
MediumThe element was unmounted when it left the cache region, so its
State— and the video controller living inside thatState— was disposed and then built again from scratch.class _ClipRowState extends State<ClipRow> with AutomaticKeepAliveClientMixin<ClipRow> { late final VideoPlayerController _video = VideoPlayerController.networkUrl(Uri.parse(widget.clip.url)) ..initialize(); … - 09
A section header has to shrink from 180px to 56px as you scroll and then stay pinned — how does SliverPersistentHeader do that?
MediumIt reserves
maxExtentof scroll space, hands your delegate ashrinkOffsetthat grows from 0 tomaxExtent - minExtent, and refuses to get smaller thanminExtent.class _SectionHeader extends SliverPersistentHeaderDelegate { const _SectionHeader({required this.title, required this.unread}); final String title; final int unread; @override double get minExtent => 56; … - 10
A collapsing SliverAppBar over a TabBarView, and the first rows of every tab hide behind the pinned bar — what is NestedScrollView doing?
HardNestedScrollView runs two separate scroll positions, and the overlap that a pinned header creates in the outer viewport is invisible to the inner one unless you hand it across explicitly.
NestedScrollView( floatHeaderSlivers: true, // required for a floating: true header sliver headerSliverBuilder: (context, innerBoxIsScrolled) => [ SliverOverlapAbsorber( // this context is already below the NestedScrollView handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), … - 11
A table needs its first column pinned while the rest scrolls horizontally, and the two halves must never drift a pixel apart — how do you link them?
HardOne
ScrollControllercannot drive two scrollables: it can hold several positions, but a drag only moves the position it belongs to, andoffsetasserts the moment a second one attaches.class _PinnedColumnTableState extends State<PinnedColumnTable> { final _left = ScrollController(); final _right = ScrollController(); bool _syncing = false; @override … - 12
Switching tabs resets the list to the top, and after Android kills the app in the background it also starts at the top — what fixes each case?
HardTwo different mechanisms:
PageStorageKeyparks the offset in an in-memory bucket that lives as long as the route, whilerestorationIdwrites it into the platform's state-restoration data so it survives the process dying.// 1. Tabs — an in-memory offset per tab, for the life of the route TabBarView( children: [ ListView.builder( key: const PageStorageKey<String>('tab.inbox'), // unique per sibling itemCount: inbox.length, … - 13
You drag a row in a SliverReorderableList and it lands one slot off, or the drop animates back to where it started — what is wrong?
HardTwo separate bugs with the same symptom:
newIndexis reported in the coordinate space of the list before the dragged item is removed, and the list identifies rows byKey, so index-based keys leave state attached to positions instead of data.class _TaskListState extends State<TaskList> { final List<Task> _tasks = seedTasks(); final List<Task> _done = doneTasks(); @override Widget build(BuildContext context) => CustomScrollView(slivers: [ … - 14
A chat loads older messages upward and new ones downward, and every history page shoves the thread down a screenful — how do you keep the anchor still?
HardPut both halves in one
CustomScrollViewaround acenterkey: everything listed before the centre sliver is laid out at negative scroll offsets, so prepending history movesminScrollExtentinstead of moving the content under the user's thumb.class _ChatState extends State<Chat> { static const _centerKey = ValueKey<String>('live'); final _controller = ScrollController(); final List<Message> _older = []; // index 0 = nearest the anchor, walking back final List<Message> _live = []; // index 0 = the anchor, growing downward … - 15
No built-in sliver produces the scroll effect you need — what must a custom RenderSliver's performLayout set, and when is SliverLayoutBuilder enough?
HardperformLayouthas exactly one obligation — assigngeometry— and the viewport asserts hard the moment those numbers are inconsistent with theSliverConstraintsit handed you./// Consumes its child's height of scroll but paints it at a fraction of the /// scroll speed — a parallax backdrop no built-in sliver gives you. class SliverParallax extends SingleChildRenderObjectWidget { const SliverParallax({super.key, super.child, this.factor = 0.5}); final double factor; … - 16
Ten thousand rows of varying height: the scrollbar thumb keeps resizing as you scroll and jumpTo(maxScrollExtent) never reaches the end. Why?
HardSliverListcannot know the height of a child it has not built, somaxScrollExtentis an extrapolation from the rows currently laid out — and it changes every time that sample changes.class Msg { const Msg(this.id, this.lines); final String id; final int lines; } …