Skip to content
Blog

Pull-to-Refresh in Flutter: RefreshIndicator Patterns and Pitfalls

Implement pull-to-refresh correctly with RefreshIndicator — the Future<void> contract, styling, CustomScrollView usage, trigger modes, and the short-list pitfall.

Published on • November 2, 2026

AI Assistant

Pull-to-refresh is the gesture users expect from any feed, and Flutter’s RefreshIndicator implements it in a few lines (docs.flutter.dev). The widget is simple; its contract — a real future and a scrollable child — is where most implementations break.

The contract: Future and a scrollable child

onRefresh must return a Future<void> that completes only when the reload actually finishes — the spinner stays up until it does. The child must be scrollable (ListView, GridView, CustomScrollView); anything else silently drops the gesture.

Future<void> _refresh() async {
  setState(() => _loading = true);
  try {
    await _repo.reload(); // the real async work
  } finally {
    if (mounted) setState(() => _loading = false);
  }
}

RefreshIndicator(
  onRefresh: _refresh, // must return Future<void>
  child: ListView.builder(
    itemCount: items.length,
    itemBuilder: (_, i) => ListTile(title: Text(items[i].title)),
  ),
);

Styling and semantics

Tint the spinner with color, the scrim behind it with backgroundColor, push it below the app bar with edgeOffset, and announce it to screen readers with semanticsLabel.

RefreshIndicator(
  onRefresh: _refresh,
  color: Theme.of(context).colorScheme.primary,
  backgroundColor: Theme.of(context).colorScheme.surface,
  edgeOffset: 12,
  semanticsLabel: 'Refresh the feed',
  triggerMode: RefreshIndicatorTriggerMode.anywhere,
  child: /* your scrollable */,
);

CustomScrollView and slivers

Feeds built from slivers get the same treatment — just make the CustomScrollView itself the child.

RefreshIndicator(
  onRefresh: _refresh,
  child: CustomScrollView(
    physics: const AlwaysScrollableScrollPhysics(),
    slivers: [
      SliverToBoxAdapter(child: _Header()),
      SliverList(
        delegate: SliverChildBuilderDelegate(
            (_, i) => ListTile(title: Text(items[i].title)),
            childCount: items.length),
      ),
    ],
  ),
);

Pitfalls and best practices

  • Short content can’t overscroll on Android — a list with two items has nothing to pull; AlwaysScrollableScrollPhysics() (above) fixes it.
  • Never complete the future early: wrap reloads in try/finally so an error can’t strand the spinner.
  • Empty and error states must be scrollable too — a plain Column child breaks the gesture; wrap it in a SingleChildScrollView.
  • triggerMode: onEdge requires the pull to start at the very top edge; anywhere is friendlier for long lists.

A correct RefreshIndicator awaits real work over a genuinely scrollable, always-scrollable child — get those two right and the rest is styling.