Physics-driven pull-to-refresh ScrollView — resisted pull, threshold snap, custom ring indicator.
components/ui/pull-to-refresh/pull-to-refresh.tsx@lumioui/core@latestreact-native-reanimated@*react-native-svg@*| refreshing? | boolean | Controlled refresh flag. Omit it and the component tracks the onRefresh promise internally (uncontrolled). |
| onRefresh? | () => void | Promise<void> | Runs when the pull passes the threshold and the finger lifts. May return a Promise — uncontrolled mode awaits it. |
| haptic? | boolean | Light haptic on threshold cross + success haptic on completion. Default true. |
| disabled? | boolean | Disable the pull gesture (content still scrolls). |
| threshold? | number | Resisted pull distance (pt) required to trigger. Default 80. |
| maxPull? | number | Resisted pull distance ceiling. Default 140. |
| holdDistance? | number | Gap the content holds open while refreshing. Default 64. |
| className? | string | Applied to the outer frame (the scroller fills it) — pass flex-1 or an explicit height exactly as you would have on the old ScrollView. |
Physics-driven pull-to-refresh ScrollView — resisted pull, threshold snap, custom ring indicator.
import { PullToRefresh } from "@/components/ui/pull-to-refresh/pull-to-refresh";
export function Example() {
return (
<PullToRefresh />
);
} import * as React from "react";
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState, } from "react";
import { PanResponder, Platform, ScrollView, View, type NativeScrollEvent, type NativeSyntheticEvent, type ScrollViewProps, } from "react-native";
import Animated, {
Easing,
interpolate,
runOnJS,
useAnimatedProps,
useAnimatedScrollHandler,
useAnimatedStyle,
useSharedValue,
withRepeat,
withSpring,
withTiming,
} from "react-native-reanimated";
import Svg, { Circle, Path } from "react-native-svg";
import { AnimatedCircle, cn, ease, spring, timing, useHaptic, useReducedMotion, useTheme, } from "@lumioui/core";
/**
* RefreshableScrollView — physics-driven pull-to-refresh with a custom
* ring/arrow indicator. Replaces the native RefreshControl wrapper with
* beui-style choreography: resisted rubber-band pull, threshold snap, a
* hold gap while refreshing, then collapse.
*
* Platform split — this is a free component and must stay Expo Go
* compatible, so react-native-gesture-handler is off the table:
*
* iOS — the ScrollView's own rubber-band bounce supplies the finger
* tracking. `useAnimatedScrollHandler` reads the negative
* contentOffset on the UI thread, maps it through the resistance
* curve, and on release decides snap vs. refresh. While refreshing,
* a spacer inside the content holds the refresh gap open.
*
* Android / other — there is no native top overscroll, so a PanResponder
* (react-native core — same approach as toast) on the wrapper
* captures clearly-vertical downward drags once the list is at the
* top, translates the whole list by the resisted distance, and
* releases into the hold gap. Caveat: once the ScrollView owns a
* gesture it can't be stolen mid-drag, so "scroll up to the top and
* keep pulling in one motion" needs a fresh touch — same constraint
* the web reference has.
*
* <RefreshableScrollView onRefresh={reload}>…</RefreshableScrollView>
* <RefreshableScrollView refreshing={busy} onRefresh={reload}>…</…>
*/
/** Indicator badge diameter (pt). */
const INDICATOR_SIZE = 36;
const RING_RADIUS = 13;
const RING_STROKE = 2.5;
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
/** Arc fraction the ring shows while spinning (progress-ring convention). */
const REFRESHING_ARC = 0.28;
/** Rotor speed — constant linear spin, same convention as loader/progress-ring. */
const SPIN_CYCLE = { duration: 1100, easing: Easing.linear };
/** Reduced-motion stand-in for the rotor: calm opacity pulse, no rotation. */
const PULSE_CYCLE = { duration: 1400, easing: Easing.bezier(...ease.inOut) };
/** Keeps the hold gap open briefly so a synchronous onRefresh can't flash
* the badge open and closed inside a single frame. */
const MIN_REFRESH_MS = 400;
/** Finger travel → resisted distance. Same exponential approach to maxPull
* as beui's resistedDistance(): every extra pixel moves the indicator a
* little less, so the pull feels like it's fighting back. */
function resist(distance: number, maxPull: number) {
"worklet";
return maxPull * (1 - Math.exp(-Math.max(0, distance) / maxPull));
}
export interface RefreshableScrollViewProps extends ScrollViewProps {
/** Controlled refresh flag. Omit it and the component tracks the
* onRefresh promise internally (uncontrolled). */
refreshing?: boolean;
/** Runs when the pull passes the threshold and the finger lifts.
* May return a Promise — uncontrolled mode awaits it. */
onRefresh?: () => void | Promise<void>;
/** Light haptic on threshold cross + success haptic on completion.
* Default true. */
haptic?: boolean;
/** Disable the pull gesture (content still scrolls). */
disabled?: boolean;
/** Resisted pull distance (pt) required to trigger. Default 80. */
threshold?: number;
/** Resisted pull distance ceiling. Default 140. */
maxPull?: number;
/** Gap the content holds open while refreshing. Default 64. */
holdDistance?: number;
/** Applied to the outer frame (the scroller fills it) — pass flex-1 or an
* explicit height exactly as you would have on the old ScrollView. */
className?: string;
}
export const RefreshableScrollView = forwardRef<
React.ElementRef<typeof ScrollView>,
RefreshableScrollViewProps
>(function RefreshableScrollView(
{
refreshing,
onRefresh,
haptic = true,
disabled = false,
threshold = 80,
maxPull = 140,
holdDistance = 64,
className,
style,
children,
onScroll: userOnScroll,
scrollEventThrottle,
// The custom indicator replaces RefreshControl — swallow the prop so a
// leftover one can't render a second spinner on top.
refreshControl: _refreshControl,
...props
},
ref
) {
const { colors } = useTheme();
const haptics = useHaptic(haptic);
const reduce = useReducedMotion();
const [internalRefreshing, setInternalRefreshing] = useState(false);
const pullThreshold = Math.max(24, threshold);
const pullLimit = Math.max(maxPull, pullThreshold + 24);
const restDistance = Math.min(Math.max(0, holdDistance), pullThreshold);
const isRefreshing = Boolean(refreshing) || internalRefreshing;
// --- shared values -----------------------------------------------------
const pull = useSharedValue(0); // resisted pull distance
const overscroll = useSharedValue(0); // iOS raw bounce distance
const scrolled = useSharedValue(0); // iOS positive scroll offset
const hold = useSharedValue(0); // refresh gap height
const refreshArc = useSharedValue(0); // arc fraction while refreshing
const spin = useSharedValue(0); // rotor angle
const pulse = useSharedValue(1); // reduced-motion pulse
const arrowFade = useSharedValue(1);
const busy = useSharedValue(0); // refreshing flag for worklets
const disabledSV = useSharedValue(disabled ? 1 : 0);
const readySV = useSharedValue(0); // pull >= threshold edge, for haptics
// Android-only JS mirrors — PanResponder can't read shared values.
const scrollYRef = useRef(0);
const canPullRef = useRef(true);
canPullRef.current = !disabled && !isRefreshing;
const readyRef = useRef(false);
useEffect(() => {
disabledSV.value = disabled ? 1 : 0;
}, [disabled, disabledSV]);
const onThresholdEnter = useCallback(() => {
haptics.light();
}, [haptics]);
const beginRefresh = useCallback(() => {
if (!canPullRef.current) return;
setInternalRefreshing(true);
const started = Date.now();
const finish = () => {
const wait = Math.max(0, MIN_REFRESH_MS - (Date.now() - started));
setTimeout(() => setInternalRefreshing(false), wait);
};
try {
Promise.resolve(onRefresh?.()).then(finish, finish);
} catch {
finish();
}
}, [onRefresh]);
// --- iOS: UI-thread overscroll tracking --------------------------------
const iosScrollHandler = useAnimatedScrollHandler(
{
onScroll: (e) => {
const y = e.contentOffset.y;
// Zero while disabled so the badge never peeks out on overscroll.
overscroll.value = disabledSV.value ? 0 : Math.max(0, -y);
scrolled.value = Math.max(0, y);
if (!busy.value && !disabledSV.value) {
pull.value = resist(overscroll.value, pullLimit);
const ready = pull.value >= pullThreshold ? 1 : 0;
if (ready !== readySV.value) {
readySV.value = ready;
if (ready) runOnJS(onThresholdEnter)();
}
}
// Best-effort passthrough — the worklet event is re-wrapped in the
// { nativeEvent } shape JS handlers expect.
if (userOnScroll) {
runOnJS(userOnScroll)({
nativeEvent: e,
} as unknown as NativeSyntheticEvent<NativeScrollEvent>);
}
},
onEndDrag: (e) => {
if (busy.value || disabledSV.value) return;
if (resist(Math.max(0, -e.contentOffset.y), pullLimit) >= pullThreshold) {
runOnJS(beginRefresh)();
}
},
},
[pullLimit, pullThreshold, userOnScroll, onThresholdEnter, beginRefresh]
);
// --- Android: JS scroll mirror + PanResponder pull ---------------------
const onJsScroll = useCallback(
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
scrollYRef.current = e.nativeEvent.contentOffset.y;
userOnScroll?.(e);
},
[userOnScroll]
);
// Capture (not bubble) so the wrapper wins the responder before the
// ScrollView claims it — only when at the top and clearly dragging down.
const panResponder = useMemo(() => {
if (Platform.OS === "ios") return null;
return PanResponder.create({
onMoveShouldSetPanResponderCapture: (_e, g) =>
canPullRef.current &&
scrollYRef.current <= 0 &&
g.dy > 8 &&
g.dy > Math.abs(g.dx),
onPanResponderMove: (_e, g) => {
if (!canPullRef.current) return;
pull.value = resist(g.dy, pullLimit);
const ready = pull.value >= pullThreshold;
if (ready !== readyRef.current) {
readyRef.current = ready;
if (ready) haptics.light();
}
},
onPanResponderRelease: () => {
if (canPullRef.current && pull.value >= pullThreshold) {
beginRefresh();
return;
}
readyRef.current = false;
pull.value = reduce ? 0 : withSpring(0, spring.dismiss);
},
onPanResponderTerminate: () => {
readyRef.current = false;
pull.value = reduce ? 0 : withSpring(0, spring.dismiss);
},
onPanResponderTerminationRequest: () => false,
});
}, [pullLimit, pullThreshold, reduce, haptics, beginRefresh, pull]);
// --- refresh choreography ----------------------------------------------
useEffect(() => {
busy.value = isRefreshing ? 1 : 0;
if (isRefreshing) {
readyRef.current = false;
refreshArc.value = withTiming(REFRESHING_ARC, timing.fast);
arrowFade.value = reduce ? 0 : withTiming(0, timing.fast);
pull.value = reduce ? 0 : withTiming(0, timing.fast);
hold.value = reduce ? restDistance : withSpring(restDistance, spring.layout);
if (reduce) {
spin.value = 0;
pulse.value = withRepeat(withTiming(0.5, PULSE_CYCLE), -1, true);
} else {
pulse.value = 1;
spin.value = 0;
spin.value = withRepeat(withTiming(360, SPIN_CYCLE), -1, false);
}
return;
}
// Back to idle — collapse fast (exits outrun entrances).
spin.value = 0;
pulse.value = 1;
readySV.value = 0;
refreshArc.value = 0;
pull.value = 0;
arrowFade.value = reduce ? 1 : withTiming(1, timing.fast);
hold.value = reduce ? 0 : withTiming(0, timing.recede);
}, [
isRefreshing,
reduce,
restDistance,
busy,
refreshArc,
arrowFade,
pull,
hold,
spin,
pulse,
readySV,
]);
// Completion tick — the moment the spinner hands the list back.
const wasRefreshing = useRef(isRefreshing);
useEffect(() => {
if (wasRefreshing.current && !isRefreshing) haptics.success();
wasRefreshing.current = isRefreshing;
}, [isRefreshing, haptics]);
// --- indicator ----------------------------------------------------------
// Reveal = height of the gap the badge rides in. iOS: raw overscroll minus
// downward scroll (the badge follows the refresh spacer off-screen).
// Android: the resisted pull itself — the list is translated by it.
const badgeStyle = useAnimatedStyle(() => {
const reveal =
(Platform.OS === "ios"
? overscroll.value - scrolled.value
: pull.value) + hold.value;
return {
opacity: interpolate(reveal, [0, 14], [0, 1], "clamp") * pulse.value,
transform: [
{ translateY: reveal - INDICATOR_SIZE },
{
scale: reduce
? 1
: interpolate(reveal, [0, pullThreshold], [0.82, 1], "clamp"),
},
],
};
});
// Android moves the whole list; iOS leaves it to the native bounce.
const shiftStyle = useAnimatedStyle(() => ({
transform: [
{
translateY: Platform.OS === "ios" ? 0 : pull.value + hold.value,
},
],
}));
// iOS refresh gap — a spacer inside the content holds it open.
const spacerStyle = useAnimatedStyle(() => ({ height: hold.value }));
const arcProps = useAnimatedProps(() => ({
strokeDashoffset:
RING_CIRCUMFERENCE *
(1 -
(busy.value
? refreshArc.value
: Math.min(pull.value / pullThreshold, 1))),
}));
const rotorStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${spin.value}deg` }],
}));
const arrowStyle = useAnimatedStyle(() => ({
opacity: arrowFade.value,
transform: [
{
rotate: `${interpolate(
Math.min(pull.value / pullThreshold, 1),
[0.5, 1],
[0, 180],
"clamp"
)}deg`,
},
],
}));
return (
// className/style land on the frame so `flex-1`/`h-*` still size the
// component — the scroller inside always fills it. overflow-hidden
// clips the badge as it slides in from above the top edge.
<View
className={cn("overflow-hidden", className)}
style={style}
collapsable={false}
{...(panResponder ? panResponder.panHandlers : {})}
>
<Animated.ScrollView
// Reanimated's ref type adds getNode() over the host ScrollView —
// the public API stays a plain ScrollView ref. ElementRef resolves
// to `never` on the circular $$AnimatedScrollView alias, so cast to
// the ref prop's own declared type instead.
ref={ref as unknown as React.ComponentPropsWithRef<typeof Animated.ScrollView>["ref"]}
style={[{ height: "100%" }, shiftStyle]}
onScroll={Platform.OS === "ios" ? iosScrollHandler : onJsScroll}
scrollEventThrottle={scrollEventThrottle ?? 16}
// Wrapper stays out of the a11y tree — the scroll content is what
// screen readers should traverse, not this container.
importantForAccessibility="no"
{...props}
>
{Platform.OS === "ios" ? <Animated.View style={spacerStyle} /> : null}
{children}
</Animated.ScrollView>
<Animated.View
pointerEvents="none"
className="absolute inset-x-0 top-0 items-center"
style={badgeStyle}
>
<View
className="items-center justify-center rounded-full border border-border bg-card shadow-sm"
style={{ width: INDICATOR_SIZE, height: INDICATOR_SIZE }}
accessibilityRole="progressbar"
accessibilityLabel={isRefreshing ? "Refreshing" : "Pull to refresh"}
accessibilityState={{ busy: isRefreshing }}
accessibilityLiveRegion="polite"
>
{/* Ring — fills with pull progress, switches to a spinning arc
segment while refreshing. -90° starts the arc at 12 o'clock. */}
<Animated.View style={rotorStyle}>
<Svg
width={INDICATOR_SIZE}
height={INDICATOR_SIZE}
viewBox={`0 0 ${INDICATOR_SIZE} ${INDICATOR_SIZE}`}
style={{ transform: [{ rotate: "-90deg" }] }}
>
<Circle
cx={INDICATOR_SIZE / 2}
cy={INDICATOR_SIZE / 2}
r={RING_RADIUS}
stroke={`${colors.primary}26`}
strokeWidth={RING_STROKE}
fill="none"
/>
<AnimatedCircle
cx={INDICATOR_SIZE / 2}
cy={INDICATOR_SIZE / 2}
r={RING_RADIUS}
stroke={colors.primary}
strokeWidth={RING_STROKE}
strokeLinecap="round"
fill="none"
strokeDasharray={`${RING_CIRCUMFERENCE} ${RING_CIRCUMFERENCE}`}
animatedProps={arcProps}
/>
</Svg>
</Animated.View>
{/* Arrow — rotates to "release" past the threshold, fades out once
the ring takes over as the spinner. Lucide arrow-down. */}
<Animated.View
className="absolute inset-0 items-center justify-center"
style={arrowStyle}
>
<Svg
width={18}
height={18}
viewBox="0 0 24 24"
fill="none"
stroke={colors.primary}
strokeWidth={2.4}
strokeLinecap="round"
strokeLinejoin="round"
>
<Path d="M12 5v14" />
<Path d="m19 12-7 7-7-7" />
</Svg>
</Animated.View>
</View>
</Animated.View>
</View>
);
});
RefreshableScrollView.displayName = "RefreshableScrollView";
/** Alias matching the family name. */
export const PullToRefresh = RefreshableScrollView;