skip to content

Scrollable Views

Flutter scrolls through viewports that build only visible children: lazy lists and grids, sliver compositions, controllers and physics, nested scroll views and paged feeds. Interviewers test laziness.

part ofFlutteroverview, primer and where to startread it →
on this pageshow

explore

questions

28

In Flutter, how do you scroll a ListView back to the top from a button using a ScrollController?

level: juniorimportance: must knowfreq 65%

answer

  1. one controller, owned by a State
  2. pass it as controller:
  3. animateTo needs duration and curve
  4. jumpTo skips the animation
  5. check hasClients, then dispose

basics

~20 s

Create a ScrollController in a State, pass it to the ListView's controller parameter, and on tap call animateTo(0, duration:, curve:) or jumpTo(0). Guard with hasClients so nothing runs before the list is attached, and dispose the controller.

solid answer

~40 s

The `ScrollController` is created once in a `State` (a field initializer or `initState`), never in `build`, and passed to the list through `controller:`. The button calls `animateTo(0, duration: ..., curve: ...)` for a visible glide, or `jumpTo(0)` to move instantly; both require an attached scroll view, so I check `hasClients` first — a callback can fire before the list is built or after it is gone. `animateTo` returns a `Future` that completes when the animation ends or is interrupted, for example by the user grabbing the list. The controller is a `ChangeNotifier`, so the `State` disposes it; one created in `build` would reset the position on each rebuild and never be disposed.

code

dart · 46 lines
dart
import 'package:flutter/material.dart';

class ForumThreadPage extends StatefulWidget {
  const ForumThreadPage({super.key, required this.posts});

  final List<String> posts;

  @override
  State<ForumThreadPage> createState() => _ForumThreadPageState();
}

class _ForumThreadPageState extends State<ForumThreadPage> {
  final ScrollController _controller = ScrollController();

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  void _backToTop() {
    if (!_controller.hasClients) return;
    _controller.animateTo(
      0,
      duration: const Duration(milliseconds: 400),
      curve: Curves.easeOutCubic,
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Thread')),
      floatingActionButton: FloatingActionButton(
        onPressed: _backToTop,
        tooltip: 'Back to top',
        child: const Icon(Icons.arrow_upward),
      ),
      body: ListView.builder(
        controller: _controller,
        itemCount: widget.posts.length,
        itemBuilder: (context, index) => ListTile(title: Text(widget.posts[index])),
      ),
    );
  }
}

go deeper

for a junior

Recall the steps: controller in the State, pass it to controller:, call animateTo or jumpTo, dispose it.

for a middle

Explain attachment and hasClients, the difference between jumpTo and animateTo, and what the animateTo future means.

for a senior

Handle initial offsets, very long lists and lazy maxScrollExtent estimates, and catch controllers created in build during review.

for a principal

Set a shared pattern for scroll controllers across screens so ownership and disposal are never left to chance.

## What a ScrollController is A **`ScrollController`** is the handle you hold to a scroll view's position. A scroll view (`ListView`, `GridView`, `CustomScrollView`, `SingleChildScrollView`) creates a **`ScrollPosition`** when it is built and **attaches** it to the controller you pass as `controller:`. Through the controller you can read the current `offset` and drive the position with `jumpTo` and `animateTo`. Because the controller outlives many builds and owns listeners, it is a `ChangeNotifier` that must be disposed. ## The back-to-top button, step by step For a long forum thread with a floating 'back to top' button: 1. **Own the controller in a `State`.** Declare it as a `final` field or create it in `initState`. Creating it inside `build` gives the list a fresh controller on every rebuild, which loses the position and leaks the old one. 2. **Pass it to the list** via `controller: _controller`. 3. **On tap, guard with `hasClients`.** `hasClients` is true once at least one `ScrollPosition` is attached. Calling `jumpTo` or `animateTo` with none attached trips the assertion `ScrollController not attached to any scroll views.` 4. **Scroll.** `animateTo(0, duration: const Duration(milliseconds: 400), curve: Curves.easeOutCubic)` glides to the top; `jumpTo(0)` moves there instantly. 5. **Dispose it** in the `State`'s `dispose`, before `super.dispose()`. ## jumpTo versus animateTo | | `jumpTo(value)` | `animateTo(value, duration:, curve:)` | |---|---|---| | Motion | instant | animated over `duration` with `curve` | | Return type | `void` | `Future<void>` | | Required arguments | the offset | offset, `duration` (non-zero) and `curve` | | Range check | none; a ballistic activity then settles an out-of-range value | stops early if it reaches an edge that does not overscroll | | Notifications | start, update and end scroll notifications | the same, spread over the animation | | Typical use | restoring a position, tests, very long jumps | user-visible navigation such as back to top | The `animateTo` future completes when the animation **ends or is interrupted** — the user dragging the list, another scroll activity starting, or the animation hitting an edge. Code that awaits it cannot assume the list actually reached the target. In widget tests, awaiting it can hang; the framework docs recommend driving it with `pumpAndSettle` instead. ## Why the hasClients guard matters - A button can be tapped during the first frame, before the list has laid out. - A timer or stream callback can fire after the route was popped and the list detached. - With a lazy list the controller knows nothing about unbuilt rows, but offset 0 is always valid, which is why back-to-top is the easy case. Jumping to the *end* relies on `maxScrollExtent`, which for `ListView.builder` is an estimate until rows are built. ## Very long lists `animateTo(0)` from 50,000 pixels down builds and lays out rows along the way for the duration of the animation. For very long threads, many apps `jumpTo` a point near the top and then `animateTo(0)` the last stretch, so the gesture still reads as motion without building thousands of rows mid-flight. ## Common mistakes - Creating `ScrollController()` inside `build`. - Forgetting `dispose`, which leaves listeners attached and shows up in leak tracking. - Reading `_controller.offset` in `initState`, before any position is attached. - Using a zero `Duration` with `animateTo` instead of calling `jumpTo`.

  • What does the Future returned by animateTo tell you?
    Only that the animation is over. It completes when the animation finishes or is interrupted — by a user drag, another scroll activity, or reaching an edge that does not overscroll. To know where the list ended up, read `offset` or `position.pixels` after it completes.
  • How do you open a screen already scrolled to a saved offset?
    Pass `ScrollController(initialScrollOffset: saved)`, which positions the first attached `ScrollPosition` without a visible jump. Calling `jumpTo` in `initState` fails because nothing is attached yet; if you must jump later, do it in a post-frame callback behind a `hasClients` check.
  • Why is scrolling to the end of a ListView.builder less reliable than scrolling to the top?
    Offset 0 is always valid, but `maxScrollExtent` for a lazy list is an estimate based on the rows built so far. `animateTo(position.maxScrollExtent)` computes its target once and may stop short as more rows are laid out; the docs suggest `Scrollable.ensureVisible` on a built child when you need a specific row.

saying these in an interview costs you the question

  • Create the ScrollController inside build so it is always fresh.
  • animateTo can be called before the list is built.
  • A ScrollController needs no dispose because it holds no resources.
  • Awaiting animateTo guarantees the list reached the target offset.
  • jumpTo refuses offsets outside the scroll range.
open as a page

In Flutter, what is the difference between a ListView built from a children list and ListView.builder, and when should you use each?

level: juniorimportance: must knowfreq 78%

basics

~20 s

ListView(children: ...) needs every child widget constructed up front, which suits short, fixed lists. ListView.builder calls itemBuilder only for indexes near the viewport, so a 5,000-row list builds just the rows on screen plus a small cache area.

open as a page

In a Flutter infinite ListView, why can one scroll fire several requests for the same page, and how do you prevent it?

level: middleimportance: must knowfreq 50%

basics

~20 s

The scroll listener fires on every offset change and extentAfter stays under the threshold while the request runs, so each event starts another fetch. Set an in-flight flag synchronously before the first await, and discard responses from before a refresh.

open as a page

In Flutter, what is a sliver, and when does a CustomScrollView with slivers beat a plain ListView?

level: middleimportance: must knowfreq 60%

basics

~20 s

A sliver is a piece of a scrollable laid out by scroll offset instead of box size. CustomScrollView stacks several slivers in one viewport, so a collapsing header, a list and a grid scroll as one lazy surface; a ListView holds exactly one list sliver.

open as a page

A Flutter page nests shrinkWrap ListViews with NeverScrollableScrollPhysics inside a SingleChildScrollView and janks with 2,000 rows; why, and how do you fix it?

level: seniorimportance: must knowfreq 55%

basics

~20 s

Inside a SingleChildScrollView a shrinkWrap list gets unbounded height, so its viewport treats every row as visible and builds and lays out all 2,000. NeverScrollableScrollPhysics only passes drags to the outer view. Fix it with one scroll view: a CustomScrollView of sliver lists.

open as a page

In Flutter, how do you make a list the user can drag into a new order with ReorderableListView, and what must onReorderItem do?

level: juniorimportance: should knowfreq 40%

basics

~20 s

Build the rows with ReorderableListView or its builder constructor, give every row a key, and in onReorderItem move the item in your data with removeAt(oldIndex) then insert(newIndex) inside setState; since Flutter 3.44 newIndex already accounts for the removal.

open as a page

In Flutter, how does RefreshIndicator's onRefresh work, and why might the pull-to-refresh spinner never appear or vanish at once?

level: juniorimportance: should knowfreq 44%

basics

~20 s

RefreshIndicator calls onRefresh, a Future<void> Function(), and keeps the spinner until that future completes. It vanishes at once if the work is not awaited, and never appears if the child cannot overscroll, such as a short list.

open as a page

In a Flutter CustomScrollView, why does a Container placed directly in the slivers list throw, and how do you fix it?

level: juniorimportance: should knowfreq 45%

basics

~20 s

CustomScrollView's slivers list accepts only sliver widgets, and a Container is a box widget, so the viewport reports it expected a RenderSliver. Wrap a single box in SliverToBoxAdapter, use sliver versions such as SliverPadding, and use SliverList for many rows.

open as a page

In Flutter, how does AnimatedList animate rows in and out, and why must insertItem and removeItem be paired with changes to your data?

level: middleimportance: should knowfreq 38%

basics

~20 s

AnimatedList keeps its own item count, starting from initialItemCount, and changes it only through AnimatedListState.insertItem and removeItem, reached with a GlobalKey. Change your data in the same step, and give removeItem a builder that draws the removed item.

open as a page

Why does a Flutter list bounce past its edge on iOS but stop at the edge on Android, and how do you change that?

level: middleimportance: should knowfreq 45%

basics

~10 s

The inherited ScrollBehavior chooses physics by platform: BouncingScrollPhysics on iOS and macOS, ClampingScrollPhysics on Android, Windows and Linux, where Material 3 adds a stretch indicator. Override per list with physics: or app-wide through MaterialApp.scrollBehavior.

open as a page

In Flutter, when should you observe scrolling with a ScrollController listener instead of a NotificationListener<ScrollNotification>?

level: middleimportance: should knowfreq 50%

basics

~20 s

A ScrollController listener fires on every offset change of a list you own and reads offset directly. NotificationListener<ScrollNotification> catches typed start, update, overscroll and end events bubbling from any scrollable below it, with metrics, no controller needed.

open as a page

In Flutter's GridView.builder, how do SliverGridDelegateWithFixedCrossAxisCount and SliverGridDelegateWithMaxCrossAxisExtent decide each tile's size?

level: middleimportance: should knowfreq 40%

basics

~20 s

FixedCrossAxisCount divides the width into a set number of columns; MaxCrossAxisExtent picks the fewest columns no wider than a maximum. Height is width divided by childAspectRatio, default 1.0, unless mainAxisExtent is set, and each tile is forced to exactly that size.

open as a page

In a Flutter ListView.builder, what do itemExtent and prototypeItem save, and when would you use itemExtentBuilder instead?

level: middleimportance: should knowfreq 42%

basics

~20 s

Both tell the list each item's main-axis extent up front, so it finds the index at any offset without laying out the items in between. itemExtent gives a number, prototypeItem measures one sample widget, and itemExtentBuilder gives a known extent per index.

open as a page

In Flutter, what problem does NestedScrollView solve, and why does it need SliverOverlapAbsorber and SliverOverlapInjector?

level: middleimportance: should knowfreq 45%

basics

~20 s

NestedScrollView links an outer scroll view holding a collapsing header to inner scroll views, typically lists inside a TabBarView, so they scroll as one. The absorber records the pinned header's overlap and the injector pads each inner list so its first rows are not hidden.

open as a page

In Flutter, how does a PageView with a PageController work, and what do viewportFraction, onPageChanged and keepPage change?

level: middleimportance: should knowfreq 45%

basics

~20 s

PageView is a scroll view that snaps to whole pages; a PageController, a ScrollController subclass, drives it with jumpToPage, animateToPage and nextPage. viewportFraction sets each page's share of the viewport, onPageChanged fires when the centred page changes, and keepPage restores the page.

open as a page

In Flutter's SliverAppBar, how do the pinned, floating and snap flags change what the bar does as the user scrolls?

level: middleimportance: should knowfreq 50%

basics

~20 s

SliverAppBar's pinned keeps the bar on screen collapsed to its toolbar; floating brings it back as soon as the user scrolls toward it, not only at the top; snap, legal only with floating, animates it fully in or out.

open as a page

A Flutter packing list built on AnimatedList sometimes deletes the wrong item or throws a RangeError when users tap quickly; what goes wrong and how do you fix it?

level: seniorimportance: should knowfreq 22%

basics

~20 s

The index a row captured goes stale: the removed row stays on screen and tappable, so a second tap removes the next item, and data changed without matching insertItem or removeItem calls makes itemBuilder index past the end.

open as a page

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?

level: seniorimportance: should knowfreq 40%

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.

open as a page

In a Flutter ListView.builder, why does an item lose its state when scrolled away, and how do AutomaticKeepAliveClientMixin and addAutomaticKeepAlives keep it?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Items beyond the visible area and cache area are removed and their State disposed. addAutomaticKeepAlives, true by default, wraps each item so it can ask to stay; the item's State must mix in AutomaticKeepAliveClientMixin, return true from wantKeepAlive and call super.build.

open as a page

A Flutter job feed jumps to the top on every reload and shifts when new postings are prepended; how do you keep the user's scroll position?

level: seniorimportance: should knowfreq 28%

basics

~20 s

Keep the list mounted during reloads instead of swapping it for a spinner, because a new scroll position starts at initialScrollOffset. For prepended items, use a CustomScrollView whose center is the existing items' sliver, so new items grow upward without moving the view.

open as a page

How would you build a Flutter hotel-detail page whose photo header collapses on scroll while a tab row sticks below it, using slivers?

level: seniorimportance: should knowfreq 35%

basics

~20 s

Use a CustomScrollView: a pinned SliverAppBar with expandedHeight and a FlexibleSpaceBar photo, then a pinned SliverPersistentHeader (or PinnedHeaderSliver) holding the tabs, then lazy SliverLists; pinned slivers stack, so the tabs stick under the collapsed bar.

open as a page

In Flutter's ReorderableListView, how do the default drag handles differ between mobile and desktop, and how do you add a custom handle?

level: middleimportance: nice to knowfreq 22%

basics

~20 s

With buildDefaultDragHandles true, phones start a drag on a long press anywhere on the row and desktops get a drag_handle icon at the trailing edge. For a custom handle, set it false and wrap your icon in ReorderableDragStartListener with the row's index.

open as a page

In Flutter, how does a PageStorageKey let a list keep its scroll offset when the user leaves a tab and comes back?

level: middleimportance: nice to knowfreq 25%

basics

~20 s

When a scroll ends, the ScrollPosition writes its pixels into the route's PageStorageBucket, under an identifier built from the PageStorageKeys above it. When the list is rebuilt, the new position reads that value back. Distinct keys give each tab's list its own slot.

open as a page

In Flutter 3.44 and later, what does a ListView's scrollCacheExtent control, and what did it replace?

level: middleimportance: nice to knowfreq 20%

basics

~20 s

scrollCacheExtent sets how far beyond each edge of the viewport a lazy list builds and lays out items before they are visible, 250 logical pixels by default. Since Flutter 3.44 it replaces the deprecated cacheExtent and cacheExtentStyle pair with one ScrollCacheExtent value.

open as a page

In Flutter, what is PrimaryScrollController, and why does tapping the iOS status bar scroll some lists to the top but not others?

level: middleimportance: nice to knowfreq 25%

basics

~20 s

PrimaryScrollController is an inherited ScrollController each route provides. Vertical scroll views on Android, iOS and Fuchsia that get no controller of their own attach to it, and Scaffold scrolls it to top on an iOS status-bar tap. A list with its own controller is skipped.

open as a page

In a Flutter CustomScrollView, what does SliverFillRemaining do, and what do its hasScrollBody and fillOverscroll flags change?

level: middleimportance: nice to knowfreq 20%

basics

~20 s

SliverFillRemaining gives one box child the viewport space left after the preceding slivers. hasScrollBody (default true) treats the child as scrollable and reserves a full viewport; false sizes it to the leftover space or the child, whichever is larger. fillOverscroll stretches it into bounce overscroll.

open as a page