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/finallyso an error can’t strand the spinner. - Empty and error states must be scrollable too — a plain
Columnchild breaks the gesture; wrap it in aSingleChildScrollView. triggerMode: onEdgerequires the pull to start at the very top edge;anywhereis 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.