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…
	
	Add table
		Add a link
		
	
		Reference in a new issue