PullDownToRefresh: init prototype
This commit is contained in:
parent
2d75ecfbe3
commit
7db55298a3
4 changed files with 217 additions and 2 deletions
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
|
@ -30,6 +30,7 @@
|
||||||
"@nanostores/persistent": "^0.9.1",
|
"@nanostores/persistent": "^0.9.1",
|
||||||
"@nanostores/solid": "^0.4.2",
|
"@nanostores/solid": "^0.4.2",
|
||||||
"@solid-primitives/event-listener": "^2.3.3",
|
"@solid-primitives/event-listener": "^2.3.3",
|
||||||
|
"@solid-primitives/intersection-observer": "^2.1.6",
|
||||||
"@solid-primitives/resize-observer": "^2.0.25",
|
"@solid-primitives/resize-observer": "^2.0.25",
|
||||||
"@solidjs/router": "^0.11.5",
|
"@solidjs/router": "^0.11.5",
|
||||||
"@suid/icons-material": "^0.7.0",
|
"@suid/icons-material": "^0.7.0",
|
||||||
|
|
|
@ -38,12 +38,14 @@ import BottomSheet from "../material/BottomSheet";
|
||||||
import { $settings } from "../settings/stores";
|
import { $settings } from "../settings/stores";
|
||||||
import { useStore } from "@nanostores/solid";
|
import { useStore } from "@nanostores/solid";
|
||||||
import { vibrate } from "../platform/hardware";
|
import { vibrate } from "../platform/hardware";
|
||||||
|
import PullDownToRefresh from "./PullDownToRefresh";
|
||||||
|
|
||||||
const TimelinePanel: Component<{
|
const TimelinePanel: Component<{
|
||||||
client: mastodon.rest.Client;
|
client: mastodon.rest.Client;
|
||||||
name: "home" | "public" | "trends";
|
name: "home" | "public" | "trends";
|
||||||
prefetch?: boolean;
|
prefetch?: boolean;
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
|
const [scrollLinked, setScrollLinked] = createSignal<HTMLElement>();
|
||||||
const [
|
const [
|
||||||
timeline,
|
timeline,
|
||||||
snapshot,
|
snapshot,
|
||||||
|
@ -108,7 +110,18 @@ const TimelinePanel: Component<{
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div>
|
<PullDownToRefresh
|
||||||
|
linkedElement={scrollLinked()}
|
||||||
|
loading={snapshot.loading}
|
||||||
|
onRefresh={() => refetchTimeline({ direction: "new" })}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref={(e) =>
|
||||||
|
setTimeout(() => {
|
||||||
|
setScrollLinked(e.parentElement!);
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
>
|
||||||
<For each={timeline}>
|
<For each={timeline}>
|
||||||
{(item, index) => {
|
{(item, index) => {
|
||||||
return (
|
return (
|
||||||
|
@ -249,8 +262,9 @@ const Home: ParentComponent = (props) => {
|
||||||
overflow: visible auto;
|
overflow: visible auto;
|
||||||
max-width: 560px;
|
max-width: 560px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 40px 16px;
|
padding: 0 16px;
|
||||||
scroll-snap-align: center;
|
scroll-snap-align: center;
|
||||||
|
overscroll-behavior-block: none;
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
200
src/timelines/PullDownToRefresh.tsx
Normal file
200
src/timelines/PullDownToRefresh.tsx
Normal file
|
@ -0,0 +1,200 @@
|
||||||
|
import {
|
||||||
|
createEffect,
|
||||||
|
createRenderEffect,
|
||||||
|
createSignal,
|
||||||
|
onCleanup,
|
||||||
|
Show,
|
||||||
|
untrack,
|
||||||
|
type Component,
|
||||||
|
type Signal,
|
||||||
|
} from "solid-js";
|
||||||
|
import { css } from "solid-styled";
|
||||||
|
import { Refresh as RefreshIcon } from "@suid/icons-material";
|
||||||
|
import { CircularProgress } from "@suid/material";
|
||||||
|
import {
|
||||||
|
createEventListener,
|
||||||
|
makeEventListener,
|
||||||
|
} from "@solid-primitives/event-listener";
|
||||||
|
import {
|
||||||
|
createViewportObserver,
|
||||||
|
createVisibilityObserver,
|
||||||
|
} from "@solid-primitives/intersection-observer";
|
||||||
|
|
||||||
|
const PullDownToRefresh: Component<{
|
||||||
|
loading?: boolean;
|
||||||
|
linkedElement?: HTMLElement;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
}> = (props) => {
|
||||||
|
let rootElement: HTMLDivElement;
|
||||||
|
const [pullDown, setPullDown] = createSignal(0);
|
||||||
|
|
||||||
|
const pullDownDistance = () => {
|
||||||
|
if (props.loading) {
|
||||||
|
return 140;
|
||||||
|
}
|
||||||
|
return Math.max(Math.min(160, pullDown()), 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const obvx = createVisibilityObserver({
|
||||||
|
threshold: 0.0001,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rootVisible = obvx(() => rootElement);
|
||||||
|
|
||||||
|
let released = true;
|
||||||
|
let v = 0;
|
||||||
|
let lts = -1;
|
||||||
|
let ds = 0;
|
||||||
|
let holding = false;
|
||||||
|
const M = 1;
|
||||||
|
const K = -10;
|
||||||
|
const D = -10;
|
||||||
|
const updatePullDown = (ts: number) => {
|
||||||
|
released = false;
|
||||||
|
try {
|
||||||
|
const x = untrack(pullDown);
|
||||||
|
const dt = lts !== -1 ? ts - lts : 1 / 60;
|
||||||
|
const fs = (ds / Math.pow(dt, 2)) * M;
|
||||||
|
const fh = (x + ds) * K + D * v;
|
||||||
|
const f = fs + fh;
|
||||||
|
const a = f / M;
|
||||||
|
v += a * dt;
|
||||||
|
if (holding && v < 0) {
|
||||||
|
v = 0
|
||||||
|
}
|
||||||
|
setPullDown(x + v * dt);
|
||||||
|
if (Math.abs(x) > 1 || Math.abs(v) > 1) {
|
||||||
|
requestAnimationFrame(updatePullDown);
|
||||||
|
} else {
|
||||||
|
v = 0;
|
||||||
|
lts = -1;
|
||||||
|
}
|
||||||
|
if (!holding && untrack(pullDownDistance) >= 160 && !props.loading && props.onRefresh) {
|
||||||
|
setTimeout(props.onRefresh, 0)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
ds = 0;
|
||||||
|
released = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleLinkedWheel = (event: WheelEvent) => {
|
||||||
|
const scrollTop = (event.target as HTMLElement).scrollTop;
|
||||||
|
if (scrollTop >= 0 && scrollTop < 1) {
|
||||||
|
ds = -(event.deltaY / window.devicePixelRatio);
|
||||||
|
if (released) {
|
||||||
|
released = false;
|
||||||
|
requestAnimationFrame(updatePullDown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
createEffect((cleanup?: () => void) => {
|
||||||
|
if (!rootVisible()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cleanup?.();
|
||||||
|
const element = props.linkedElement;
|
||||||
|
if (!element) return;
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
lastTouchScreenY = item.screenY;
|
||||||
|
if (released) {
|
||||||
|
released = false;
|
||||||
|
requestAnimationFrame(updatePullDown);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
lastTouchId = undefined;
|
||||||
|
lastTouchScreenY = 0;
|
||||||
|
holding = false;
|
||||||
|
if (untrack(pullDownDistance) >= 160 && !props.loading && props.onRefresh) {
|
||||||
|
setTimeout(props.onRefresh, 0)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
createEffect((cleanup?: () => void) => {
|
||||||
|
if (!rootVisible()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cleanup?.();
|
||||||
|
const element = props.linkedElement;
|
||||||
|
if (!element) return;
|
||||||
|
const cleanup0 = makeEventListener(element, "touchmove", handleTouch);
|
||||||
|
const cleanup1 = makeEventListener(element, "touchend", handleTouchEnd);
|
||||||
|
return () => (cleanup0(), cleanup1());
|
||||||
|
});
|
||||||
|
|
||||||
|
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(${`${pullDownDistance() - 2}px`});
|
||||||
|
will-change: transform;
|
||||||
|
z-index: var(--tutu-zidx-nav);
|
||||||
|
background-color: var(--tutu-color-surface);
|
||||||
|
|
||||||
|
> :global(.refresh-icon) {
|
||||||
|
transform: rotate(
|
||||||
|
${`${((pullDownDistance() / 160) * 180).toString()}deg`}
|
||||||
|
);
|
||||||
|
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;
|
Loading…
Reference in a new issue