Pull-to-refresh
Drag a list down from the top to reload its content.
The user drags the list down while it is at the top. An indicator follows the finger. Releasing past a threshold reloads the content; releasing before it cancels.
When to use it
- Lists that change on the server: inbox, feed, orders, notifications.
- As a second way to refresh. The primary way must stay reachable without a gesture (see Accessibility).
Don’t use it on content that doesn’t change, on a form, or on a list whose top is not the newest content (a chat thread loads older messages at the top).
How it works
A pull goes through five statuses. Each frame below is drawn at real size, with the default threshold of 64pt.
At rest, list at the top.
Below threshold. Releasing cancels.
Past threshold. Releasing refreshes.
onRefresh runs, held at threshold.
Returning to 0, pulls ignored.
| Status | What the user sees | What the code does |
|---|---|---|
idle | The list at rest. | Waits for a drag down while the scroll offset is 0. |
pulling | The indicator fills with the finger. | Updates distance and progress on the UI thread. |
armed | The indicator is full, the arrow flips. | Fires onArmed once (light haptic). |
refreshing | A spinner, the list held open. | Awaits onRefresh, for at least minDuration. |
settling | The list returns to its place. | Animates distance to 0, ignores new pulls. |
Going back under the threshold before releasing returns to pulling, so the user can change their mind.
Resistance
The list doesn’t follow the finger one to one. The further it goes, the harder it gets, and it never goes past maxDistance.
distance = (drag * maxDistance) / (drag + maxDistance);
progress = Math.min(distance / threshold, 1);With the defaults (threshold 64, maxDistance 160), the user drags 107pt to arm the refresh.
Implementation
Axiom implements it with one hook, useRefreshControl, built on:
| Library | Used for |
|---|---|
react-native-reanimated | distance and progress as shared values, so the indicator moves on the UI thread without a React render per frame. useAnimatedScrollHandler reads the scroll, withTiming animates the return. |
react-native-gesture-handler | The pull on Android, where lists don’t bounce: a Pan gesture recognized together with the list’s native scroll. |
| React state | status, which changes a few times per pull. |
One pull, two platforms
iOS and Android don’t give the same signal, so the hook reads each one and converts it into the same distance:
iOS: the list bounces Android: the list stops at 0
contentOffset.y goes below 0 Pan gesture, active only when offset.y === 0
drag = -contentOffset.y drag = translationY
│ │
└──────────────► resistance ◄────────────┘
│
distance, progress, status
│
your indicator- iOS:
useAnimatedScrollHandlerreads the overscroll. Duringrefreshing, a topcontentInsetequal tothresholdholds the list open. - Android: the
Pangesture runs alongsideGesture.Native()on the list, composed into oneGesture.Simultaneous. It gives the gesture back as soon as the list scrolls away from the top, and moves the list with an animatedtranslateY.
Both open the same gap above the list, so an indicator written once works on both. The indicator doesn’t create that gap: it is positioned over it, threshold tall. Giving it a height that follows distance would move the content twice on iOS, where the bounce has already moved it.
On release
minDuration prevents the spinner from flashing when the request answers in 50ms. If onRefresh rejects, the list still settles; the hook doesn’t catch the error, so show it from onRefresh with a Toast.
In a screen
const refresh = useRefreshControl({
onRefresh: refetch,
onArmed: haptics.light,
});
<View style={{ flex: 1 }}>
<PullIndicator control={refresh} />
<GestureDetector gesture={refresh.gesture}>
<Animated.FlatList
data={data}
renderItem={renderItem}
{...refresh.scrollProps}
/>
</GestureDetector>
</View>;scrollProps carries the scroll handler, the inset and the animated style; gesture goes on the GestureDetector around the list, because a gesture can’t travel as a prop. The list must be an Animated one. It works with ScrollView, FlatList, SectionList and FlashList. Options, return value and a custom indicator are documented in useRefreshControl.
Accessibility
- Screen reader users can’t pull. Keep a refresh button in the AppBar or an
accessibilityActionsentry on the list, and callrefresh()from it. - Announce the result when it matters (“3 new messages”).
- With Reduce Motion on, the list jumps back to
0and the spinner is replaced by a static state.