PullDownToRefresh: init prototype
This commit is contained in:
		
							parent
							
								
									2d75ecfbe3
								
							
						
					
					
						commit
						7db55298a3
					
				
					 4 changed files with 217 additions and 2 deletions
				
			
		| 
						 | 
				
			
			@ -38,12 +38,14 @@ import BottomSheet from "../material/BottomSheet";
 | 
			
		|||
import { $settings } from "../settings/stores";
 | 
			
		||||
import { useStore } from "@nanostores/solid";
 | 
			
		||||
import { vibrate } from "../platform/hardware";
 | 
			
		||||
import PullDownToRefresh from "./PullDownToRefresh";
 | 
			
		||||
 | 
			
		||||
const TimelinePanel: Component<{
 | 
			
		||||
  client: mastodon.rest.Client;
 | 
			
		||||
  name: "home" | "public" | "trends";
 | 
			
		||||
  prefetch?: boolean;
 | 
			
		||||
}> = (props) => {
 | 
			
		||||
  const [scrollLinked, setScrollLinked] = createSignal<HTMLElement>();
 | 
			
		||||
  const [
 | 
			
		||||
    timeline,
 | 
			
		||||
    snapshot,
 | 
			
		||||
| 
						 | 
				
			
			@ -108,7 +110,18 @@ const TimelinePanel: Component<{
 | 
			
		|||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <div>
 | 
			
		||||
      <PullDownToRefresh
 | 
			
		||||
        linkedElement={scrollLinked()}
 | 
			
		||||
        loading={snapshot.loading}
 | 
			
		||||
        onRefresh={() => refetchTimeline({ direction: "new" })}
 | 
			
		||||
      />
 | 
			
		||||
      <div
 | 
			
		||||
        ref={(e) =>
 | 
			
		||||
          setTimeout(() => {
 | 
			
		||||
            setScrollLinked(e.parentElement!);
 | 
			
		||||
          }, 0)
 | 
			
		||||
        }
 | 
			
		||||
      >
 | 
			
		||||
        <For each={timeline}>
 | 
			
		||||
          {(item, index) => {
 | 
			
		||||
            return (
 | 
			
		||||
| 
						 | 
				
			
			@ -249,8 +262,9 @@ const Home: ParentComponent = (props) => {
 | 
			
		|||
      overflow: visible auto;
 | 
			
		||||
      max-width: 560px;
 | 
			
		||||
      height: 100%;
 | 
			
		||||
      padding: 40px 16px;
 | 
			
		||||
      padding: 0 16px;
 | 
			
		||||
      scroll-snap-align: center;
 | 
			
		||||
      overscroll-behavior-block: none;
 | 
			
		||||
 | 
			
		||||
      @media (max-width: 600px) {
 | 
			
		||||
        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