Implementing Custom Pull-to-Refresh in React Native
This guide explains how to build a fully custom pull‑to‑refresh component in React Native by either handling gestures with a ScrollView and PanResponder to animate an indicator, or leveraging iOS’s native elastic bounce, outlining implementation details, limitations, and when to choose each approach.
Web applications typically refresh data by clicking a button or using Ctrl+F5, which is not convenient on mobile devices where screen space is limited. Consequently, various swipe and gesture mechanisms have become common for triggering actions, with pull‑to‑refresh being the most widely used for data updates on mobile clients.
The pull‑to‑refresh mechanism was first introduced by Loren Brichter in Tweetie 2 (later acquired by Twitter) and was patented in the United States as Patent 8448084. The patent claims cover displaying a scrollable list, accepting scroll‑related input, showing a refresh trigger, and refreshing the list content once the trigger is activated.
The mechanism consists of three states: “pull down to refresh”, “release to refresh”, and “refreshing animation”. After the patent, many news‑feed‑oriented mobile apps adopted this design.
React Native offers the RefreshControl component, which can be used inside ScrollView or FlatList . Internally it wraps iOS’s UIRefreshControl and Android’s AndroidSwipeRefreshLayout . However, RefreshControl only allows limited customization (e.g., indicator color and optional text) and is constrained by platform‑specific parameters.
To achieve a fully custom pull‑to‑refresh UI, two solutions are presented.
Solution 1 – Using ScrollView with PanResponder
ScrollView includes a gesture‑responder system that can determine a user’s true intent (tap, swipe, etc.). The PanResponder class provides a predictable wrapper for handling multi‑touch gestures and exposes a gestureState object.
Key fields of nativeEvent (e.g., changedTouches , identifier , locationX , pageY ) and gestureState (e.g., moveX , dx , vx , numberActiveTouches ) are listed in the article.
PanResponder.create({
onStartShouldSetPanResponder: (evt, gestureState) => true,
onMoveShouldSetPanResponder: (evt, gestureState) => true,
onPanResponderGrant: (evt, gestureState) => {
// start gesture, give visual feedback
},
onPanResponderMove: (evt, gestureState) => {
// handle dragging
},
onPanResponderRelease: (evt, gestureState) => {
// trigger refresh if threshold reached
},
onShouldBlockNativeResponder: (evt, gestureState) => true,
});The onPanResponderMove handler updates an Animated.Value (e.g., containerTop ) based on gestureState.dy , while onPanResponderRelease checks whether the drag distance exceeds a configurable refreshTriggerHeight and calls props.onRefresh() if so.
onPanResponderMove(event, gestureState) {
if (gestureState.dy >= 0) {
if (gestureState.dy < 120) {
this.state.containerTop.setValue(gestureState.dy);
}
} else {
this.state.containerTop.setValue(0);
if (this.scrollRef) {
if (typeof this.scrollRef.scrollToOffset === 'function') {
this.scrollRef.scrollToOffset({ offset: -gestureState.dy, animated: true });
} else if (typeof this.scrollRef.scrollTo === 'function') {
this.scrollRef.scrollTo({ y: -gestureState.dy, animated: true });
}
}
}
} onPanResponderRelease(event, gestureState) {
const threshold = this.props.refreshTriggerHeight || this.props.headerHeight;
if (this.containerTranslateY >= threshold) {
this.props.onRefresh();
} else {
this._resetContainerPosition();
}
this._checkScroll();
}When the ScrollView ’s scrollEnabled is set to false , the view acts solely as a container. By animating an Animated.View with translateY , a custom loading indicator can be displayed.
<Animated.View style={[{ flex: 1, transform: [{ translateY: this.state.containerTop }] }]}>
{child}
</Animated.View>Drawbacks of this approach include increased data communication and re‑rendering leading to UI lag on large data sets, gesture interruption when toggling scrollEnabled , and the absence of a natural damping function.
Solution 2 – Leveraging iOS Elastic Bounce
On iOS, ScrollView provides an elastic bounce when the content size is smaller than the view. By placing the refresh indicator at the top edge, pulling down reveals it without extra gesture handling.
The implementation monitors onScroll , onScrollBeginDrag , and onScrollEndDrag to update refresh status, display appropriate titles, and invoke props.onRefresh() when the release threshold is met.
onScroll = (event) => {
const { y } = event.nativeEvent.contentOffset;
this._offsetY = y;
if (this._dragFlag && !this._isRefreshing) {
const height = this.props.refreshViewHeight;
if (y <= -height) {
this.setState({ refreshStatus: RefreshStatus.releaseToRefresh, refreshTitle: this.props.refreshableTitleRelease });
} else {
this.setState({ refreshStatus: RefreshStatus.pullToRefresh, refreshTitle: this.props.refreshableTitlePull });
}
}
if (this.props.onScroll) this.props.onScroll(event);
}; onScrollEndDrag = (event) => {
const { y } = event.nativeEvent.contentOffset;
const height = this.props.refreshViewHeight;
if (!this._isRefreshing && this.state.refreshStatus === RefreshStatus.releaseToRefresh) {
this._isRefreshing = true;
this.setState({ refreshStatus: RefreshStatus.refreshing, refreshTitle: this.props.refreshableTitleRefreshing });
this._scrollview.scrollTo({ x: 0, y: -height, animated: true });
this.props.onRefresh();
} else if (y <= 0) {
this._scrollview.scrollTo({ x: 0, y: -height, animated: true });
}
if (this.props.onScrollEndDrag) this.props.onScrollEndDrag(event);
};The main limitation is that Android does not support the same bounce behavior, requiring a separate adaptation.
Both solutions are demonstrated in Expo demos (pulltorefresh1 and pulltorefresh2). The article concludes that developers can choose the approach that best fits their business requirements and customize the pull‑to‑refresh experience accordingly.
References to the original patent, React Native documentation, and related GitHub repositories are provided at the end of the article.
NetEase Cloud Music Tech Team
Official account of NetEase Cloud Music Tech Team
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.