135 lines
3.7 KiB
TypeScript
135 lines
3.7 KiB
TypeScript
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<void>;
|
|
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<HTMLDivElement>(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 (
|
|
<div
|
|
ref={rootRef}
|
|
className={['dukang-ptr', className].filter(Boolean).join(' ')}
|
|
style={{ ...style, paddingTop: pad || undefined }}
|
|
>
|
|
<div
|
|
className={`dukang-ptr-indicator${showHint ? ' dukang-ptr-indicator--visible' : ''}`}
|
|
style={{ height: pad }}
|
|
aria-live="polite"
|
|
>
|
|
<span className="dukang-ptr-text">
|
|
{refreshing ? '刷新中…' : ready ? '松开刷新' : '下拉刷新'}
|
|
</span>
|
|
</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|