import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; import './pull-to-refresh.css'; const PULL_THRESHOLD = 64; const MAX_PULL = 96; type PullToRefreshProps = { onRefresh: () => void | Promise; children: ReactNode; className?: string; style?: CSSProperties; disabled?: boolean; }; function getScrollTop() { return window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0; } export default function PullToRefresh({ onRefresh, children, className, style, disabled = false, }: PullToRefreshProps) { const rootRef = useRef(null); const [pull, setPull] = useState(0); const [refreshing, setRefreshing] = useState(false); const startY = useRef(0); const pulling = useRef(false); const busy = useRef(false); const pullRef = useRef(0); const disabledRef = useRef(disabled); const onRefreshRef = useRef(onRefresh); disabledRef.current = disabled; onRefreshRef.current = onRefresh; pullRef.current = pull; const finishRefresh = useCallback(async () => { if (busy.current) return; busy.current = true; setRefreshing(true); setPull(40); pullRef.current = 40; try { await onRefreshRef.current(); } finally { setRefreshing(false); setPull(0); pullRef.current = 0; busy.current = false; pulling.current = false; } }, []); useEffect(() => { const el = rootRef.current; if (!el) return; const onTouchStart = (e: TouchEvent) => { if (disabledRef.current || busy.current) return; if (getScrollTop() > 2) return; startY.current = e.touches[0]?.clientY ?? 0; pulling.current = true; }; const onTouchMove = (e: TouchEvent) => { if (!pulling.current || disabledRef.current || busy.current) return; if (getScrollTop() > 2) { pulling.current = false; setPull(0); pullRef.current = 0; return; } const y = e.touches[0]?.clientY ?? 0; const delta = y - startY.current; if (delta <= 0) { setPull(0); pullRef.current = 0; return; } const next = Math.min(MAX_PULL, delta * 0.45); setPull(next); pullRef.current = next; if (next > 8 && e.cancelable) e.preventDefault(); }; const onTouchEnd = () => { if (!pulling.current || disabledRef.current) return; pulling.current = false; if (pullRef.current >= PULL_THRESHOLD) { void finishRefresh(); return; } setPull(0); pullRef.current = 0; }; el.addEventListener('touchstart', onTouchStart, { passive: true }); el.addEventListener('touchmove', onTouchMove, { passive: false }); el.addEventListener('touchend', onTouchEnd); el.addEventListener('touchcancel', onTouchEnd); return () => { el.removeEventListener('touchstart', onTouchStart); el.removeEventListener('touchmove', onTouchMove); el.removeEventListener('touchend', onTouchEnd); el.removeEventListener('touchcancel', onTouchEnd); }; }, [finishRefresh]); const showHint = pull > 8 || refreshing; const ready = pull >= PULL_THRESHOLD; const pad = showHint ? Math.max(pull, refreshing ? 40 : 0) : 0; return (
{refreshing ? '刷新中…' : ready ? '松开刷新' : '下拉刷新'}
{children}
); }