tutu/src/timelines/PullDownToRefresh.tsx
2025-01-04 17:10:54 +08:00

227 lines
5.5 KiB
TypeScript

import {
createEffect,
createSignal,
Show,
untrack,
type Component,
} from "solid-js";
import { css } from "solid-styled";
import { Refresh as RefreshIcon } from "@suid/icons-material";
import { CircularProgress } from "@suid/material";
import { makeEventListener } from "@solid-primitives/event-listener";
import { createVisibilityObserver } from "@solid-primitives/intersection-observer";
import { useIsFrameSuspended } from "~platform/StackedRouter";
const PullDownToRefresh: Component<{
loading?: boolean;
linkedElement?: HTMLElement;
onRefresh?: () => void;
}> = (props) => {
let rootElement!: HTMLDivElement;
const [pullDown, setPullDown] = createSignal(0);
const stopPos = () => 160;
const indicatorOfsY = () => {
if (props.loading) {
return stopPos() * 0.875;
}
return pullDown();
};
const obvx = createVisibilityObserver({
threshold: 0.0001,
});
const rootVisible = obvx(() => rootElement);
const isFrameSuspended = useIsFrameSuspended()
createEffect(() => {
if (!rootVisible()) setPullDown(0);
});
let released = true;
let v = 0;
let lts = -1;
let ds = 0;
let holding = false;
const K = 20;
const updatePullDown = (ts: number) => {
released = false;
try {
const x = untrack(pullDown);
const dt = lts !== -1 ? ts - lts : 1 / 60;
const vspring = holding ? 0 : K * x * dt;
v = ds / dt - vspring;
const final = Math.max(Math.min(x + v * dt, stopPos()), 0);
setPullDown(final);
if (Math.abs(x) > 1 || Math.abs(v) > 1) {
requestAnimationFrame(updatePullDown);
} else {
v = 0;
lts = -1;
}
} finally {
ds = 0;
released = true;
}
};
let wheelTimeout: ReturnType<typeof setTimeout> | undefined;
const onWheelNotUpdated = () => {
wheelTimeout = undefined;
holding = false;
if (released) {
released = false;
requestAnimationFrame(updatePullDown);
}
};
const handleLinkedWheel = (event: WheelEvent) => {
const scrollTop = (event.target as HTMLElement).scrollTop;
if (scrollTop >= 0 && scrollTop < 1) {
const d = untrack(pullDown);
if (d > 1) event.preventDefault();
ds = -(event.deltaY / window.devicePixelRatio / 2);
if (wheelTimeout) {
clearTimeout(wheelTimeout);
}
if (d >= stopPos() && !props.loading) {
props.onRefresh?.();
holding = false;
wheelTimeout = undefined;
} else {
holding = true;
wheelTimeout = setTimeout(onWheelNotUpdated, 200);
}
if (released) {
released = false;
requestAnimationFrame(updatePullDown);
}
}
};
createEffect(() => {
if (!rootVisible()) {
return;
}
if (isFrameSuspended()) {
return;
}
const element = props.linkedElement;
if (!element) return;
makeEventListener(element, "wheel", handleLinkedWheel);
});
let lastTouchId: number | undefined = undefined;
let lastTouchScreenY = 0;
const handleTouch = (event: TouchEvent) => {
if (event.targetTouches.length > 1) {
lastTouchId = 0;
lastTouchScreenY = 0;
return;
}
const item = event.targetTouches.item(0)!;
if (lastTouchId && item.identifier !== lastTouchId) {
lastTouchId = undefined;
lastTouchScreenY = 0;
return;
}
holding = true;
if (lastTouchScreenY !== 0) {
ds = item.screenY - lastTouchScreenY;
}
if (Math.abs(ds) > 1 && untrack(pullDown) > 1) event.preventDefault();
lastTouchScreenY = item.screenY;
if (released) {
released = false;
requestAnimationFrame(updatePullDown);
}
};
const handleTouchEnd = () => {
lastTouchId = undefined;
lastTouchScreenY = 0;
holding = false;
if (untrack(pullDown) >= stopPos() && !props.loading) {
props.onRefresh?.();
} else {
if (released) {
released = false;
requestAnimationFrame(updatePullDown);
}
}
};
createEffect(() => {
if (!rootVisible()) {
return;
}
if (isFrameSuspended()) {
return;
}
const element = props.linkedElement;
if (!element) return;
makeEventListener(element, "touchmove", handleTouch);
makeEventListener(element, "touchend", handleTouchEnd);
});
css`
.pull-down {
width: 100%;
display: flex;
justify-content: center;
margin-top: -2rem;
height: calc(1px + 2rem);
}
.indicator {
display: inline-flex;
justify-content: center;
align-items: center;
box-shadow: ${props.loading
? "var(--tutu-shadow-e12)"
: "var(--tutu-shadow-e1)"};
border-radius: 50%;
aspect-ratio: 1/1;
width: 2rem;
color: var(--tutu-color-primary);
transform: translateY(${`${indicatorOfsY() - 2}px`});
will-change: transform;
z-index: var(--tutu-zidx-nav);
background-color: var(--tutu-color-surface);
> :global(.refresh-icon) {
transform: rotate(${`${(indicatorOfsY() / 160 / 2).toString()}turn`});
will-change: transform;
}
> :global(.refresh-indicator) {
width: 1.5rem;
height: 1.5rem;
aspect-ratio: 1/1;
}
}
`;
return (
<div ref={rootElement!} class="pull-down">
<span class="indicator">
<Show
when={props.loading}
fallback={<RefreshIcon class="refresh-icon" />}
>
<CircularProgress class="refresh-indicator" />
</Show>
</span>
</div>
);
};
export default PullDownToRefresh;