Making Flutter apps look more native. Part 2: refresh indicators

Roman Ismagilov
3 min readApr 30, 2024

Android and iOS have different refreshing logic. On iOS it is common to drag the content downward to trigger a refresh, while on Android the content stays at the same place and the indicator is drawn on a separate layer on top of it, making it possible to scroll the content and still see the indicator.

Flutter has both implementations, but they work differently. IOS implementation is based on Slivers, while on Android it’s as simple as wrapping the content in a RefreshIndicator widget.

To provide users with native-like experience, let’s make a wrapper widget that could be used across the app.

First, let’s create the widget itself:

class AdaptiveRefreshIdicator extends StatelessWidget {
final Widget child;
final Future<void> Function() onRefresh;

const AdaptiveRefreshIdicator({
super.key,
required this.child,
required this.onRefresh,
});

@override
Widget build(BuildContext context) {
if (defaultTargetPlatform == TargetPlatform.iOS) {
return _IOSAdaptiveRefreshIdicator(
onRefresh: onRefresh, key: key, child: child);
}
return _AndroidAdaptiveRefreshIdicator(
onRefresh: onRefresh, key: key, child: child);
}
}

Nothing complex here, just a callback and a child.

Android implementation is below:

class _AndroidAdaptiveRefreshIdicator extends StatelessWidget {
final Widget child;
final Future<void> Function() onRefresh;

const _AndroidAdaptiveRefreshIdicator({
super.key,
required this.child,
required this.onRefresh,
});

@override
Widget build(BuildContext context) {
return RefreshIndicator(onRefresh: onRefresh, child: SingleChildScrollView(child: child));
}
}

For iOS we need to use a CustomScrollView to include CupertinoSliverRefreshControl. Why not use RefreshIndicator.adaptive you might ask? Problem with it is that it work a bit weirdly:

class _IOSAdaptiveRefreshIdicator extends StatelessWidget {
final Widget child;
final Future<void> Function() onRefresh;

const _IOSAdaptiveRefreshIdicator({
super.key,
required this.child,
required this.onRefresh,
});

@override
Widget build(BuildContext context) {
return CustomScrollView(
physics: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics(),
),
slivers: [
CupertinoSliverRefreshControl(
onRefresh: onRefresh,
),
SliverToBoxAdapter(
child: child,
)
],
);
}
}

This code snippet works with Columns or other non-scrollable children.

The code could be found here: https://github.com/Pomis/flutter_native_ui_examples/tree/main/lib/2_adaptive_refresh_indicator

But what if we have a case when we want to utilise list items recycling, which is needed for large lists to still be smooth when scrolled or when we want to have more complex UI (Parallax effect, Sticky headers)?

In that case we should rework our logic a bit, so that our AdaptiveRefreshIdicator would accept slivers as constructor argument and then pass it down. Code example with slivers could be found here: https://github.com/Pomis/flutter_native_ui_examples/tree/main/lib/2_adaptive_refresh_indicator_advanced

Hope you have found this article useful.

--

--

Roman Ismagilov

Covering some non-obvious nuances of Flutter development in my articles