skip to content

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%

answer

  1. a callback that returns a Future
  2. spinner lives until it completes
  3. needs overscroll from the child
  4. AlwaysScrollableScrollPhysics for short lists
  5. triggerMode onEdge by default

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.

solid answer

~50 s

`RefreshIndicator` wraps a vertical scroll view and listens to its scroll notifications. When the user drags down from the top far enough, it calls `onRefresh`, typed `RefreshCallback`, a `Future<void> Function()`, and shows the spinner until that future completes. So the callback must `await` the real reload; `() async { reload(); }` completes immediately and the spinner disappears while the load is still running. The spinner never appears when the child cannot be dragged: a list that fits on screen accepts no drag unless it uses `physics: const AlwaysScrollableScrollPhysics()`, and with the default `triggerMode: RefreshIndicatorTriggerMode.onEdge` the drag must start with the list already at the top. By default it also reacts only to the nearest scrollable, since `notificationPredicate` checks `depth == 0`. For a paged list, refresh loads the first page, replaces the items only on success, and resets the cursor and `hasMore`.

code

dart · 30 lines
dart
// Inside a State that owns a JobPager and a ScrollController.
@override
Widget build(BuildContext context) {
  return RefreshIndicator(
    onRefresh: _refresh,
    child: ListView.builder(
      controller: _controller,
      // Lets a short list, or an empty one, still be pulled.
      physics: const AlwaysScrollableScrollPhysics(),
      itemCount: _pager.jobs.length + (_pager.hasMore ? 1 : 0),
      itemBuilder: (context, index) => index < _pager.jobs.length
          ? ListTile(title: Text(_pager.jobs[index].title))
          : const Padding(
              padding: EdgeInsets.all(16),
              child: Center(child: CircularProgressIndicator()),
            ),
    ),
  );
}

Future<void> _refresh() async {
  try {
    await _pager.refresh(); // awaited: the spinner stays until page 0 is back
  } catch (_) {
    if (!mounted) return;
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('Could not refresh job postings')),
    );
  }
}

go deeper

for a junior

Remember that onRefresh returns a Future and the spinner stays until it completes, so await the reload inside it.

for a middle

Explain why short or empty content cannot be pulled, what AlwaysScrollableScrollPhysics changes, and what triggerMode and notificationPredicate default to.

for a senior

Show how refresh fits the pager: generation bump, replace only on success, a failure message over the old list, and the list kept mounted so the position survives.

for a principal

Decide on one refresh behaviour across screens, Material or adaptive look, what a failed refresh shows, and whether refresh resets to the newest page or merges.

## What RefreshIndicator does `RefreshIndicator` is the Material **swipe-to-refresh** widget. It wraps a scroll view, typically a `ListView` or `CustomScrollView`, and listens to the scroll notifications that the scroll view sends up the tree. When the user drags the list past its top edge far enough, a circular spinner slides in, and when the finger lifts the widget calls its `onRefresh` callback. The source states the limit plainly: a `RefreshIndicator` can only be used with a vertical scroll view. Key parameters and defaults in Flutter 3.47: - `onRefresh` (required): a `RefreshCallback`, which is `Future<void> Function()`; - `displacement`: where the spinner settles, `40.0` logical pixels from the edge by default; - `edgeOffset`: where it starts to appear, `0.0` by default, useful under a pinned header; - `triggerMode`: `RefreshIndicatorTriggerMode.onEdge` by default; - `notificationPredicate`: `defaultScrollNotificationPredicate`, which accepts only notifications with `depth == 0`. ## The onRefresh contract The indicator stays in its refreshing state until the future returned by `onRefresh` completes, then fades out. That is the whole contract, and it explains the most common bug: | Callback | What the user sees | |---|---| | `() => pager.refresh()` returning the real future | spinner until the first page is back | | `() async { await pager.refresh(); }` | same, and room for a try/catch | | `() async { pager.refresh(); }` | spinner vanishes almost at once while the load continues | If the future completes with an error, the indicator still hides, but the error is not handled by anyone and surfaces as an unhandled exception. Catch it inside the callback and tell the user, for example with a `SnackBar`, while keeping the old list on screen. ## Why the spinner never appears The indicator reacts to a drag that overscrolls the top of its child. Common reasons it does not: 1. **The content fits on screen.** Scroll physics accept a user drag only when there is something to scroll, so a three-row list, an empty state or an error message gives no drag at all. Setting `physics: const AlwaysScrollableScrollPhysics()` on the list makes it accept the drag anyway; this is the fix the framework's own troubleshooting note gives. 2. **The child is not scrollable.** An empty or error state built as a plain `Column` or `Center` sends no scroll notifications. Wrap it in a scroll view (a `ListView` with one child works) so the pull still reaches the indicator. 3. **The drag did not start at the top.** With `onEdge`, the drag must begin when the list is already at its top edge. `RefreshIndicatorTriggerMode.anywhere` also lets a drag that starts mid-list arm the indicator once it reaches the edge. 4. **The scroll view is nested.** The default predicate only accepts `depth == 0`, the nearest scrollable. To refresh from an inner list, pass a `notificationPredicate` that accepts the right depth. 5. **The list is horizontal.** Not supported. ## What a refresh should do to a paged list For an endless feed of job postings loaded 25 at a time, pull-to-refresh means 'start over from the newest page': 1. bump a generation counter so any running load-more response is ignored when it lands; 2. request page 0 (or the first cursor); 3. on success, replace the items, set the next page to 1 and recompute `hasMore`; 4. on failure, keep the old items and show a message; the old list is still useful; 5. keep the list widget on screen throughout, so the scroll position is not torn down. ## Platform look and other entry points - `RefreshIndicator.adaptive` shows a `CupertinoActivityIndicator` on iOS and macOS and the Material spinner elsewhere, decided from `ThemeData.platform`. - `RefreshIndicator.noSpinner` calls `onRefresh` without drawing a spinner and reports progress through `onStatusChange`, for a custom indicator. - `CupertinoSliverRefreshControl` is the iOS-style pull control. It is a **sliver** placed first in a `CustomScrollView`, not a wrapper, because it is part of the scrollable content. Its defaults are a trigger distance of `100.0` and an indicator extent of `60.0`. - `RefreshIndicatorState.show()`, reached through a `GlobalKey<RefreshIndicatorState>`, runs the same refresh programmatically, for example from a toolbar button, and returns the `onRefresh` future.

  • How do you start the same refresh from a button instead of a pull?
    Give the `RefreshIndicator` a `GlobalKey<RefreshIndicatorState>` and call `key.currentState?.show()`. It animates the spinner in, runs `onRefresh` and returns that future; if a refresh is already running it does nothing new. Calling the reload directly would work too, but the user would get no spinner.
  • What changes if you use CupertinoSliverRefreshControl instead?
    It is a sliver, so it goes first in a `CustomScrollView`'s slivers rather than wrapping the scroll view, and it takes part in the scroll content instead of floating over it. It also takes an `onRefresh` that returns a future and keeps its activity indicator until that completes. `RefreshIndicator.adaptive` is the simpler option when you only want the iOS-style spinner.

saying these in an interview costs you the question

  • onRefresh can fire the reload without awaiting it, since the spinner hides itself.
  • RefreshIndicator works on any list, even one shorter than the screen, with default physics.
  • An empty state in a Center widget can still be pulled to refresh.
  • Replace the whole list with a full-screen spinner while the refresh runs.
  • RefreshIndicator also works on horizontal lists.