Gesture
Pull to Refresh
A resisted pull reveals a little face and triggers an asynchronous refresh
Expo code
Original web component
Be UIOur Expo component
React Native WebComponent source
Make it yours
One file to copy into your app.
pull-to-refresh.tsx
import React, { useEffect, useState } from "react"
import { Text, View, type StyleProp, type ViewStyle } from "react-native"
import { Gesture, GestureDetector } from "react-native-gesture-handler"
import Animated, {
Easing,
interpolate,
useAnimatedProps,
useAnimatedScrollHandler,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withRepeat,
withSpring,
withTiming,
} from "react-native-reanimated"
import { scheduleOnRN } from "react-native-worklets"
import Svg, { Circle, G, Path, Rect } from "react-native-svg"
const AG = Animated.createAnimatedComponent(G)
export interface PullToRefreshProps {
children: React.ReactNode
onRefresh: () => void | Promise<void>
refreshing?: boolean
disabled?: boolean
threshold?: number
maxPull?: number
holdHeight?: number
height?: number
contentContainerStyle?: StyleProp<ViewStyle>
backgroundColor?: string
textColor?: string
pullLabel?: string
releaseLabel?: string
refreshingLabel?: string
testID?: string
}
export function PullToRefresh({
children,
onRefresh,
refreshing,
disabled = false,
threshold = 76,
maxPull = 132,
holdHeight = 68,
height = 340,
contentContainerStyle,
backgroundColor = "#1e1e1e",
textColor = "#888",
pullLabel = "Pull to refresh",
releaseLabel = "Release to refresh",
refreshingLabel = "Refreshing",
testID,
}: PullToRefreshProps) {
const [localBusy, setLocalBusy] = useState(false),
[ready, setReady] = useState(false),
busy = refreshing ?? localBusy
const reduced = useReducedMotion(),
offset = useSharedValue(0),
scrollY = useSharedValue(0),
active = useSharedValue(false),
readyUI = useSharedValue(false),
spin = useSharedValue(0)
async function refresh() {
if (busy) return
setLocalBusy(true)
try {
await onRefresh()
} finally {
setLocalBusy(false)
setReady(false)
offset.value = withSpring(0, { damping: 40, stiffness: 420, mass: 0.5 })
}
}
useEffect(() => {
offset.value = reduced
? busy
? holdHeight
: 0
: withSpring(busy ? holdHeight : 0, { damping: 40, stiffness: 420, mass: 0.5 })
spin.value =
busy && !reduced ? withRepeat(withTiming(360, { duration: 900, easing: Easing.bezier(0.77, 0, 0.175, 1) }), -1) : 0
}, [busy, holdHeight, reduced, offset, spin])
const native = Gesture.Native()
const pull = Gesture.Pan()
.simultaneousWithExternalGesture(native)
.activeOffsetY(8)
.failOffsetX([-24, 24])
.enabled(!disabled && !busy)
.onStart(() => {
active.value = scrollY.value <= 0
})
.onUpdate((e) => {
if (!active.value) return
offset.value =
maxPull * (1 - Math.exp(-Math.max(0, e.translationY) / maxPull))
const next = offset.value >= threshold
if (next !== readyUI.value) {
readyUI.value = next
scheduleOnRN(setReady, next)
}
})
.onEnd(() => {
if (active.value && readyUI.value) {
offset.value = withSpring(holdHeight, { damping: 40, stiffness: 420, mass: 0.5 })
scheduleOnRN(refresh)
} else offset.value = withSpring(0, { damping: 40, stiffness: 420, mass: 0.5 })
readyUI.value = false
active.value = false
})
.onFinalize(() => {
if (active.value)
offset.value = withSpring(0, { damping: 40, stiffness: 420, mass: 0.5 })
active.value = false
})
const scrolled = useAnimatedScrollHandler((e) => {
scrollY.value = e.contentOffset.y
})
const content = useAnimatedStyle(() => ({
transform: [{ translateY: offset.value }],
}))
const indicator = useAnimatedStyle(() => ({
opacity: interpolate(offset.value, [0, 10, threshold], [0, 0.45, 1], "clamp"),
transform: [
{ scale: reduced ? 1 : interpolate(offset.value, [0, threshold], [0.86, 1], "clamp") },
],
}))
const buddy = useAnimatedStyle(() => {
const p = Math.min(1, offset.value / threshold)
return { transform: [{ translateY: reduced ? 0 : -7+7*p }, { rotate: `${reduced ? 0 : -10+10*p}deg` }, { scaleY: reduced ? 1 : interpolate(p, [0,.55,1], [.68,1.1,.92]) }] }
})
const orbit = useAnimatedProps(() => ({ transform: `translate(18 18) rotate(${busy ? spin.value : ready ? 0 : -35}) translate(-18 -18)` }))
const eyes = useAnimatedProps(() => ({ transform: `translate(18 16) scale(1 ${busy && !reduced ? interpolate(spin.value,[0,90,180,270,360],[1,1,.15,1,1]) : ready ? 1.18 : 1}) translate(-18 -16)` }))
return (
<View
testID={testID}
style={{ height, borderRadius: 22, backgroundColor, overflow: "hidden" }}
>
<Animated.View
pointerEvents="none"
style={[
{
position: "absolute",
top: 0,
left: 0,
right: 0,
alignItems: "center",
height: 68,
justifyContent: "center",
gap: 2,
},
indicator,
]}
>
<Animated.View style={buddy}>
<Svg width={36} height={36} viewBox="0 0 36 36">
<AG animatedProps={orbit} opacity={ready || busy ? 1 : 0}><Path d="M18 2.5a15.5 15.5 0 0 1 12.7 6.6" fill="none" stroke={textColor} strokeWidth={1.5} strokeLinecap="round" /><Circle cx={31.3} cy={10.2} r={2.2} fill="#fff" /></AG>
<Rect x={7} y={7} width={22} height={22} rx={9} fill="#fff" />
<AG animatedProps={eyes}><Circle cx={14.2} cy={16} r={1.45} fill="#141414" /><Circle cx={21.8} cy={16} r={1.45} fill="#141414" /></AG>
<Path d={ready ? "M14 20.5c1 2.4 7 2.4 8 0" : "M14.5 21h7"} fill="none" stroke="#141414" strokeWidth={1.5} strokeLinecap="round" opacity={busy ? 0 : 1} />
{busy && <Circle cx={18} cy={21} r={1.6} fill="#141414" />}
</Svg>
</Animated.View>
<Text
accessibilityLiveRegion="polite"
style={{ color: textColor, fontSize: 11 }}
>
{busy ? refreshingLabel : ready ? releaseLabel : pullLabel}
</Text>
</Animated.View>
<GestureDetector gesture={pull}>
<Animated.View style={[{ flex: 1 }, content]}>
<GestureDetector gesture={native}>
<Animated.ScrollView
bounces={false}
overScrollMode="never"
onScroll={scrolled}
scrollEventThrottle={16}
showsVerticalScrollIndicator={false}
contentContainerStyle={[{
padding: 20,
paddingBottom: maxPull + 20,
}, contentContainerStyle]}
accessibilityActions={[
{ name: "refresh", label: "Refresh content" },
]}
onAccessibilityAction={(e) => {
if (
e.nativeEvent.actionName === "refresh" &&
!busy &&
!disabled
)
void refresh()
}}
>
{children}
</Animated.ScrollView>
</GestureDetector>
</Animated.View>
</GestureDetector>
</View>
)
}
Give the gestures and motion a run on a real Android phone.