Irbisa · Cheatsheet

Slivers & Scrolling

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

Senior Developer · 22 questions

  1. 01

    What are slivers, and when do you build a screen with CustomScrollView instead of a ListView?

    A sliver is a piece of a scrollable area that lays itself out from the current scroll offset instead of from a box size.

  2. 02

    What happens when this CustomScrollView is laid out, and how do you fix it?

    CustomScrollView(  slivers: [    const SliverAppBar(title: Text('Orders'), pinned: true),    const Padding(      padding: EdgeInsets.all(16),      child: Text('Recent orders'),    ),    ListView.builder(      itemCount: orders.length,      itemBuilder: (_, i) => OrderTile(order: orders[i]),    ),  ],)

    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".

  3. 03

    How do you add pull-to-refresh, and why does RefreshIndicator sometimes not trigger when the list is shorter than the screen?

    Wrap the scrollable in RefreshIndicator and return a Future from onRefresh; the spinner stays until it completes.

    RefreshIndicator(  onRefresh: () => feed.reload(),      // spinner stays until this completes  child: ListView.builder(    controller: _scroll,               // own controller: no longer primary,    physics: const AlwaysScrollableScrollPhysics(), // so restore the drag    itemCount: posts.length,…
  4. 04

    How does SliverAppBar build a collapsing header: what do pinned, floating, snap, expandedHeight and FlexibleSpaceBar each do?

    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.

    // inside DefaultTabController(length: 2, child: Scaffold(body: ...))CustomScrollView(  slivers: [    SliverAppBar(      pinned: true,          // toolbar + TabBar stay on screen      floating: true,        // reappears on any upward scroll…
  5. 05

    How does SliverPersistentHeader shrink a section header from 180 px to 56 px and keep it pinned, and what does shouldRebuild decide?

    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.

    class _SectionHeader extends SliverPersistentHeaderDelegate {  const _SectionHeader({required this.title, required this.unread});  final String title;  final int unread;   @override…
  6. 06

    What does shrinkWrap: true do, why is it a performance smell, and how do you put a list inside another scrollable correctly?

    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.

    // wrong: every order is built and laid out in one frame, plus a second// Scrollable that never scrollsSingleChildScrollView(  child: Column(children: [    const SummaryCard(),    ListView.builder(…
  7. 07

    How do you use a ScrollController (listen to the offset, jumpTo/animateTo, trigger load-more), and why does reading offset in initState throw?

    A ScrollController gives you the attached ScrollPosition: read offset or position.extentAfter in a listener and move with jumpTo or animateTo.

    class _FeedState extends State<Feed> {  final _scroll = ScrollController();   @override  void initState() {    super.initState();…
  8. 08

    What is wrong with this infinite-scroll code, and why does it request the same page several times?

    class _FeedState extends State<Feed> {  @override  Widget build(BuildContext context) {    final controller = ScrollController();    controller.addListener(() {      if (controller.position.extentAfter < 500) {        widget.onLoadMore();      }    });    return ListView.builder(      controller: controller,      itemCount: widget.items.length,      itemBuilder: (_, i) => ItemTile(item: widget.items[i]),    );  }}

    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.

  9. 09

    Find the bug: after the user sends a message, the newest message is hidden just below the visible area.

    void _send(Message m) {  setState(() => _messages.add(m));  _scroll.animateTo(    _scroll.position.maxScrollExtent,    duration: const Duration(milliseconds: 250),    curve: Curves.easeOut,  );} @overrideWidget build(BuildContext context) => ListView.builder(      controller: _scroll,      itemCount: _messages.length,      itemBuilder: (_, i) => Bubble(message: _messages[i]),    );

    setState only marks the list dirty; the new row is built and laid out in the next frame.

  10. 10

    When do you use NotificationListener<ScrollNotification> instead of a ScrollController, and why does a nested carousel trigger your feed's load-more?

    A controller reports only the position it is attached to; a NotificationListener hears every scrollable below it, including ones you did not create.

    bool _onScroll(ScrollNotification n) {  // wrong without this line: the carousel below also triggers load-more  if (n.depth != 0) return false;  if (n is ScrollUpdateNotification && n.metrics.extentAfter < 600) {    loader.loadNextPage();  }…
  11. 11

    Find the bug: on tablets the feed shows the first page of 10 posts and never loads the next one; on phones paging works.

    @overridevoid initState() {  super.initState();  _scroll.addListener(_onScroll); // the parent has already loaded page 1} void _onScroll() {  if (_scroll.position.extentAfter < 400) widget.onLoadMore(); // guarded} @overrideWidget build(BuildContext context) => ListView.builder(      controller: _scroll,      itemCount: widget.posts.length,      itemBuilder: (_, i) => PostTile(post: widget.posts[i]),    );

    A ScrollController listener runs only when the offset changes.

  12. 12

    What do ScrollPhysics control (Bouncing, Clamping, NeverScrollable, AlwaysScrollable), and how do you change the scroll feel for the whole app?

    ScrollPhysics decides how a scroll position reacts to a drag, to the edges and to a fling.

    // Layered on top of the platform default, not instead of it.ListView.builder(  physics: const BouncingScrollPhysics(    parent: AlwaysScrollableScrollPhysics(), // still draggable when short  ),  itemCount: items.length,…
  13. 13

    How many rows beyond the visible window does ListView.builder actually build, and what does that mean for a row that fires a request in initState?

    Everything within 250 logical pixels above and below the viewport is built and laid out but not painted: that is the default cacheExtent.

  14. 14

    How do you scroll a lazy list to item 500 when that row has never been built?

    You need the row's scroll offset, and a lazy list knows it only for rows it has laid out.

    const rowHeight = 72.0; ListView.builder(  controller: _scroll,  itemExtent: rowHeight,               // offset of row i is i * rowHeight  itemCount: items.length,…
  15. 15

    In this ReorderableListView, a row dragged downward lands one slot below the drop point, and row state ends up on the wrong task. What are the two bugs?

    ReorderableListView.builder(  itemCount: tasks.length,  onReorder: (oldIndex, newIndex) {    setState(() {      final task = tasks.removeAt(oldIndex);      tasks.insert(newIndex, task);    });  },  itemBuilder: (context, i) => TaskTile(    key: ValueKey(i),    task: tasks[i],  ),)

    newIndex is measured while the dragged row is still in the list, so a downward move must subtract one before inserting.

  16. 16

    How does AutomaticKeepAliveClientMixin keep a list row alive after it scrolls away, how does it fail silently, and what does it cost?

    A lazy sliver unmounts rows that leave the viewport plus cache extent, disposing their State.

    class _ClipRowState extends State<ClipRow>    with AutomaticKeepAliveClientMixin<ClipRow> {  late final VideoPlayerController _video =      VideoPlayerController.networkUrl(Uri.parse(widget.clip.url))        ..initialize();…
  17. 17

    When do you need NestedScrollView, and why do the first rows of each tab hide behind its pinned SliverAppBar?

    You need it when a collapsing header sits above a TabBarView or PageView whose pages scroll on their own.

    // inside DefaultTabController(length: 2, child: Scaffold(body: ...))NestedScrollView(  headerSliverBuilder: (context, innerBoxIsScrolled) => [    SliverOverlapAbsorber(      handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),      sliver: SliverAppBar(…
  18. 18

    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?

    Two mechanisms.

    // 1. Tabs: an in-memory offset per tab, for the life of the routeTabBarView(children: [  ListView.builder(    key: const PageStorageKey<String>('tab.inbox'), // unique per tab    itemCount: inbox.length,    itemBuilder: (_, i) => MailTile(mail: inbox[i]),…
  19. 19

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

    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.

    // height is a pure function of the data: no widget needed to know itdouble extentOf(Msg m) => 16 + m.lines * 22.0; // wrong: heights are discovered by building, maxScrollExtent is an estimateListView.builder(  itemCount: msgs.length,…
  20. 20

    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?

    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.

    class _ChatState extends State<Chat> {  static const _centerKey = ValueKey<String>('live');  final List<Message> _older = []; // index 0 = nearest the anchor  final List<Message> _live = [];  // index 0 = the anchor, grows downward   Future<void> _loadHistory() async {…
  21. 21

    A table needs its first column pinned while the rest scrolls, and the two halves must never drift a pixel apart. How do you link them?

    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.

    class _PinnedTableState extends State<PinnedTable> {  final _left = ScrollController();  final _right = ScrollController();  bool _syncing = false;   @override…
  22. 22

    A custom RenderSliver paints in the right place but the next sliver starts too low. What must performLayout set, and when is SliverLayoutBuilder enough?

    performLayout must assign geometry; the viewport starts the next sliver after your layoutExtent, not after what you painted.

    // Reserves 200 px of scroll but pins its last 56 px:// the three extents deliberately disagree.class RenderPinnedBanner extends RenderSliver {  static const double _full = 200;  static const double _pinned = 56;…