Pull-to-refresh
Pre-alphaThe registry and the CLI are not published yet.Roadmap
Scroll

Pull-to-refresh

Drag a list down from the top to reload its content.

Inbox
threshold 64
maxDistance 160
  • GGrace HopperFound the bug. It was a moth.
  • AAlan TuringCan a machine think? Lunch first.
  • KKatherine JohnsonTrajectory numbers attached.
  • LLinus TorvaldsJust a hobby, won’t be big.
  • MMargaret HamiltonPriority alarms are fine.
status idle · progress 0.00 · distance 0

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.

idle
distance 0

At rest, list at the top.

pulling
distance 36

Below threshold. Releasing cancels.

armed
distance 88

Past threshold. Releasing refreshes.

refreshing
distance 64

onRefresh runs, held at threshold.

settling
distance 20

Returning to 0, pulls ignored.

StatusWhat the user seesWhat the code does
idleThe list at rest.Waits for a drag down while the scroll offset is 0.
pullingThe indicator fills with the finger.Updates distance and progress on the UI thread.
armedThe indicator is full, the arrow flips.Fires onArmed once (light haptic).
refreshingA spinner, the list held open.Awaits onRefresh, for at least minDuration.
settlingThe 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.

0641600100200300400no resistancemaxDistance 160threshold 64armed after 107pt of dragfinger drag (pt)distance (pt)
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:

LibraryUsed for
react-native-reanimateddistance 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-handlerThe pull on Android, where lists don’t bounce: a Pan gesture recognized together with the list’s native scroll.
React statestatus, 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: useAnimatedScrollHandler reads the overscroll. During refreshing, a top contentInset equal to threshold holds the list open.
  • Android: the Pan gesture runs alongside Gesture.Native() on the list, composed into one Gesture.Simultaneous. It gives the gesture back as soon as the list scrolls away from the top, and moves the list with an animated translateY.

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

pullpast thresholdback underreleasereleasedone · 500ms minat restidlepullingarmedrefreshingsettling

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 accessibilityActions entry on the list, and call refresh() from it.
  • Announce the result when it matters (“3 new messages”).
  • With Reduce Motion on, the list jumps back to 0 and the spinner is replaced by a static state.