Slivers & Scrolling
Irbisa · cheatsheetSeptember 13, 2026

Slivers & Scrolling

CustomScrollView, SliverAppBar, ScrollController, ScrollPhysics, NestedScrollView, keep-alives

Senior Developer16 itemscompressed for a skim
  1. 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?

    Medium

    layoutExtent — 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;
    …
  2. 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?

    Medium

    A SliverToBoxAdapter is 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)]),
      ),
    ]);
    …
  3. 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?

    Medium

    snap means nothing without floating: true — the assert is literal — and expandedHeight is 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
    …
  4. 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?

    Medium

    Let something narrow listen to the controller instead of calling setState from 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() {
    …
  5. 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?

    Medium

    ScrollNotifications bubble up the element tree from every scrollable below you, so the carousel's updates reach the feed's listener; depth is 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) {
    …
  6. 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?

    Medium

    You 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
    …
  7. 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?

    Medium

    Everything 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,
    …
  8. 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?

    Medium

    The element was unmounted when it left the cache region, so its State — and the video controller living inside that State — 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();
    …
  9. 09

    A section header has to shrink from 180px to 56px as you scroll and then stay pinned — how does SliverPersistentHeader do that?

    Medium

    It reserves maxExtent of scroll space, hands your delegate a shrinkOffset that grows from 0 to maxExtent - minExtent, and refuses to get smaller than minExtent.

    class _SectionHeader extends SliverPersistentHeaderDelegate {
      const _SectionHeader({required this.title, required this.unread});
      final String title;
      final int unread;
    
      @override double get minExtent => 56;
    …
  10. 10

    A collapsing SliverAppBar over a TabBarView, and the first rows of every tab hide behind the pinned bar — what is NestedScrollView doing?

    Hard

    NestedScrollView 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. 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?

    Hard

    One ScrollController cannot drive two scrollables: it can hold several positions, but a drag only moves the position it belongs to, and offset asserts the moment a second one attaches.

    class _PinnedColumnTableState extends State<PinnedColumnTable> {
      final _left = ScrollController();
      final _right = ScrollController();
      bool _syncing = false;
    
      @override
    …
  12. 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?

    Hard

    Two different mechanisms: PageStorageKey parks the offset in an in-memory bucket that lives as long as the route, while restorationId writes 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. 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?

    Hard

    Two separate bugs with the same symptom: newIndex is reported in the coordinate space of the list before the dragged item is removed, and the list identifies rows by Key, 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. 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?

    Hard

    Put both halves in one CustomScrollView around a center key: everything listed before the centre sliver is laid out at negative scroll offsets, so prepending history moves minScrollExtent instead 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. 15

    No built-in sliver produces the scroll effect you need — what must a custom RenderSliver's performLayout set, and when is SliverLayoutBuilder enough?

    Hard

    performLayout has exactly one obligation — assign geometry — and the viewport asserts hard the moment those numbers are inconsistent with the SliverConstraints it 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. 16

    Ten thousand rows of varying height: the scrollbar thumb keeps resizing as you scroll and jumpTo(maxScrollExtent) never reaches the end. Why?

    Hard

    SliverList cannot know the height of a child it has not built, so maxScrollExtent is 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;
    }
    …