227 lines
5.5 KiB
TypeScript
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;
|