BottomSheet: first attempt for animation

This commit is contained in:
thislight 2024-08-12 17:25:03 +08:00
parent 1c0a83dbab
commit 2d7b931ef8
No known key found for this signature in database
GPG key ID: A50F9451AC56A63E
13 changed files with 196 additions and 20 deletions

BIN
bun.lockb

Binary file not shown.

View file

@ -7,6 +7,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>Tutu</title> <title>Tutu</title>
<link rel="stylesheet" href="/src/App.css" /> <link rel="stylesheet" href="/src/App.css" />
<script src="/src/platform/polyfills.ts" type="module"></script>
<script src="/src/index.tsx" type="module"></script> <script src="/src/index.tsx" type="module"></script>
</head> </head>
<body> <body>

View file

@ -42,7 +42,8 @@
"nanostores": "^0.9.5", "nanostores": "^0.9.5",
"solid-js": "^1.8.18", "solid-js": "^1.8.18",
"solid-styled": "^0.11.1", "solid-styled": "^0.11.1",
"stacktrace-js": "^2.0.2" "stacktrace-js": "^2.0.2",
"web-animations-js": "^2.3.2"
}, },
"packageManager": "bun@1.1.21" "packageManager": "bun@1.1.21"
} }

View file

@ -22,6 +22,7 @@ const AccountMastodonOAuth2Callback = lazy(
); );
const TimelineHome = lazy(() => import("./timelines/Home.js")); const TimelineHome = lazy(() => import("./timelines/Home.js"));
const Settings = lazy(() => import("./settings/Settings.js")); const Settings = lazy(() => import("./settings/Settings.js"));
const TootBottomSheet = lazy(() => import("./timelines/TootBottomSheet.js"));
const Routing: Component = () => { const Routing: Component = () => {
return ( return (
@ -29,6 +30,7 @@ const Routing: Component = () => {
<Route path="/" component={TimelineHome}> <Route path="/" component={TimelineHome}>
<Route path=""></Route> <Route path=""></Route>
<Route path="/settings" component={Settings}></Route> <Route path="/settings" component={Settings}></Route>
<Route path="/:acct/:id" component={TootBottomSheet}></Route>
</Route> </Route>
<Route path={"/accounts"}> <Route path={"/accounts"}>
<Route path={"/sign-in"} component={AccountSignIn} /> <Route path={"/sign-in"} component={AccountSignIn} />
@ -53,7 +55,7 @@ const App: Component = () => {
); );
}); });
const UnexpectedError = lazy(() => import("./UnexpectedError.js")) const UnexpectedError = lazy(() => import("./UnexpectedError.js"));
return ( return (
<ErrorBoundary <ErrorBoundary

View file

@ -30,4 +30,9 @@
max-height: 100%; max-height: 100%;
} }
} }
&.animated {
position: absolute;
transform: none;
}
} }

View file

@ -1,25 +1,88 @@
import { createEffect, type ParentComponent } from "solid-js"; import {
createEffect,
createRenderEffect,
onCleanup,
onMount,
type ParentComponent,
} from "solid-js";
import styles from "./BottomSheet.module.css"; import styles from "./BottomSheet.module.css";
import { useHeroSignal } from "../platform/anim";
export type BottomSheetProps = { export type BottomSheetProps = {
open?: boolean; open?: boolean;
}; };
export const HERO = Symbol("BottomSheet Hero Symbol");
function composeAnimationFrame({
top,
left,
height,
width,
}: Record<"top" | "left" | "height" | "width", number>) {
return {
top: `${top}px`,
left: `${left}px`,
height: `${height}px`,
width: `${width}px`,
};
}
const MOVE_SPEED = 1400; // 1400px/s, bottom sheet is big and a bit heavier than small papers
const BottomSheet: ParentComponent<BottomSheetProps> = (props) => { const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
let element: HTMLDialogElement; let element: HTMLDialogElement;
let animation: Animation | undefined;
const hero = useHeroSignal(HERO);
createEffect(() => { createEffect(() => {
if (props.open) { if (props.open) {
if (!element.open) { if (!element.open) {
element.showModal(); element.showModal();
animateOpen();
} }
} else { } else {
if (element.open) { if (element.open) {
if (animation) {
animation.cancel();
}
element.close(); element.close();
} }
} }
}); });
const animateOpen = () => {
// Do hero animation
const startRect = hero();
console.debug("capture hero source", startRect);
if (!startRect) return;
const endRect = element.getBoundingClientRect();
const easing = "ease-in-out";
console.debug("easing", easing);
element.classList.add(styles.animated);
const distance = Math.sqrt(
Math.pow(Math.abs(startRect.top - endRect.top), 2) +
Math.pow(Math.abs(startRect.left - startRect.top), 2),
);
const duration = (distance / MOVE_SPEED) * 1000;
animation = element.animate(
[composeAnimationFrame(startRect), composeAnimationFrame(endRect)],
{ easing, duration },
);
const onAnimationEnd = () => {
element.classList.remove(styles.animated);
animation = undefined;
};
animation.addEventListener("finish", onAnimationEnd);
animation.addEventListener("cancel", onAnimationEnd);
};
onCleanup(() => {
if (animation) {
animation.cancel();
}
});
return ( return (
<dialog class={styles.bottomSheet} ref={element!}> <dialog class={styles.bottomSheet} ref={element!}>
{props.children} {props.children}

View file

@ -1,13 +1,47 @@
import { createContext, useContext, type Accessor } from "solid-js"; import {
createContext,
createRenderEffect,
createSignal,
untrack,
useContext,
type Accessor,
type Signal,
} from "solid-js";
export type HeroSource = { export type HeroSource = {
[key: string | symbol | number]: HTMLElement | undefined; [key: string | symbol | number]: DOMRect | undefined;
}; };
const HeroSourceContext = createContext<Accessor<HeroSource>>(() => ({})); const HeroSourceContext = createContext<Signal<HeroSource>>(undefined);
export const HeroSourceProvider = HeroSourceContext.Provider; export const HeroSourceProvider = HeroSourceContext.Provider;
export function useHeroSource() { function useHeroSource() {
return useContext(HeroSourceContext); return useContext(HeroSourceContext);
} }
export function useHeroSignal(
key: string | symbol | number,
): Accessor<DOMRect | undefined> {
const source = useHeroSource();
if (source) {
const [get, set] = createSignal<DOMRect>();
createRenderEffect(() => {
const value = source[0]();
console.debug("value", value);
if (value[key]) {
set(value[key]);
source[1]((x) => {
const cpy = Object.assign({}, x);
delete cpy[key];
return cpy;
});
}
});
return get;
} else {
return () => undefined;
}
}

12
src/platform/host.ts Normal file
View file

@ -0,0 +1,12 @@
export function isiOS() {
return [
'iPad Simulator',
'iPhone Simulator',
'iPod Simulator',
'iPad',
'iPhone',
'iPod'
].includes(navigator.platform)
// iPad on iOS 13 detection
|| (navigator.userAgent.includes("Mac") && "ontouchend" in document)
}

View file

@ -0,0 +1,8 @@
//! This module has side effect.
//! It recommended to include the module by <script> tag.
if (!document.body.animate) {
// @ts-ignore: this file is polyfill, no exposed decls
import("web-animations-js").then(() => {
console.warn("web animation polyfill is included");
});
}

View file

@ -34,16 +34,25 @@ import Tab from "../material/Tab";
import { Create as CreateTootIcon } from "@suid/icons-material"; import { Create as CreateTootIcon } from "@suid/icons-material";
import { useTimeline } from "../masto/timelines"; import { useTimeline } from "../masto/timelines";
import { makeEventListener } from "@solid-primitives/event-listener"; import { makeEventListener } from "@solid-primitives/event-listener";
import BottomSheet from "../material/BottomSheet"; import BottomSheet, {
HERO as BOTTOM_SHEET_HERO,
} 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"; import PullDownToRefresh from "./PullDownToRefresh";
import { HeroSourceProvider, type HeroSource } from "../platform/anim";
import { useNavigate } from "@solidjs/router";
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;
openFullScreenToot: (
toot: mastodon.v1.Status,
srcElement?: HTMLElement,
) => void;
}> = (props) => { }> = (props) => {
const [scrollLinked, setScrollLinked] = createSignal<HTMLElement>(); const [scrollLinked, setScrollLinked] = createSignal<HTMLElement>();
const [ const [
@ -125,18 +134,22 @@ const TimelinePanel: Component<{
> >
<For each={timeline}> <For each={timeline}>
{(item, index) => { {(item, index) => {
let element: HTMLElement | undefined;
return ( return (
<TootThread <TootThread
ref={element}
status={item} status={item}
onBoost={(...args) => onBoost(index(), ...args)} onBoost={(...args) => onBoost(index(), ...args)}
onBookmark={(...args) => onBookmark(index(), ...args)} onBookmark={(...args) => onBookmark(index(), ...args)}
client={props.client} client={props.client}
expanded={item.id === expandedThreadId() ? 1 : 0} expanded={item.id === expandedThreadId() ? 1 : 0}
onExpandChange={() => onExpandChange={(x) => {
setExpandedThreadId( if (item.id !== expandedThreadId()) {
item.id !== expandedThreadId() ? item.id : undefined, setExpandedThreadId(item.id);
) } else if (x === 2){
} props.openFullScreenToot(item, element);
}
}}
/> />
); );
}} }}
@ -185,7 +198,9 @@ const Home: ParentComponent = (props) => {
const sessions = useSessions(); const sessions = useSessions();
const client = () => sessions()[0].client; const client = () => sessions()[0].client;
const [profile] = useAcctProfile(client); const [profile] = useAcctProfile(client);
const navigate = useNavigate();
const [heroSrc, setHeroSrc] = createSignal<HeroSource>({});
const [panelOffset, setPanelOffset] = createSignal(0); const [panelOffset, setPanelOffset] = createSignal(0);
const prefetching = () => !settings$().prefetchTootsDisabled; const prefetching = () => !settings$().prefetchTootsDisabled;
const [currentFocusOn, setCurrentFocusOn] = createSignal<HTMLElement[]>([]); const [currentFocusOn, setCurrentFocusOn] = createSignal<HTMLElement[]>([]);
@ -259,6 +274,22 @@ const Home: ParentComponent = (props) => {
} }
}; };
const openFullScreenToot = (
toot: mastodon.v1.Status,
srcElement?: HTMLElement,
) => {
const p = sessions()[0];
const inf = p.account.inf ?? profile();
if (!inf) {
console.warn('no account info?')
return;
}
const rect = srcElement?.getBoundingClientRect();
setHeroSrc((x) => Object.assign({}, x, { [BOTTOM_SHEET_HERO]: rect }));
const acct = `${inf.username}@${p.account.site}`;
navigate(`/${encodeURIComponent(acct)}/${toot.id}`);
};
css` css`
.tab-panel { .tab-panel {
overflow: visible auto; overflow: visible auto;
@ -344,6 +375,7 @@ const Home: ParentComponent = (props) => {
client={client()} client={client()}
name="home" name="home"
prefetch={prefetching()} prefetch={prefetching()}
openFullScreenToot={openFullScreenToot}
/> />
</div> </div>
</div> </div>
@ -353,6 +385,7 @@ const Home: ParentComponent = (props) => {
client={client()} client={client()}
name="trends" name="trends"
prefetch={prefetching()} prefetch={prefetching()}
openFullScreenToot={openFullScreenToot}
/> />
</div> </div>
</div> </div>
@ -362,13 +395,16 @@ const Home: ParentComponent = (props) => {
client={client()} client={client()}
name="public" name="public"
prefetch={prefetching()} prefetch={prefetching()}
openFullScreenToot={openFullScreenToot}
/> />
</div> </div>
</div> </div>
<div></div> <div></div>
</div> </div>
</TimeSourceProvider> </TimeSourceProvider>
<BottomSheet open={!!child()}>{child()}</BottomSheet> <HeroSourceProvider value={[heroSrc, setHeroSrc]}>
<BottomSheet open={!!child()}>{child()}</BottomSheet>
</HeroSourceProvider>
</Scaffold> </Scaffold>
</> </>
); );

View file

@ -15,8 +15,6 @@ import {
untrack, untrack,
} from "solid-js"; } from "solid-js";
import { css } from "solid-styled"; import { css } from "solid-styled";
import { useHeroSource } from "../platform/anim";
import { Portal } from "solid-js/web";
import { createStore } from "solid-js/store"; import { createStore } from "solid-js/store";
import { IconButton, Toolbar } from "@suid/material"; import { IconButton, Toolbar } from "@suid/material";
import { ArrowLeft, ArrowRight, Close } from "@suid/icons-material"; import { ArrowLeft, ArrowRight, Close } from "@suid/icons-material";
@ -42,8 +40,6 @@ function clamp(input: number, min: number, max: number) {
const MediaViewer: ParentComponent<MediaViewerProps> = (props) => { const MediaViewer: ParentComponent<MediaViewerProps> = (props) => {
let rootRef: HTMLDialogElement; let rootRef: HTMLDialogElement;
const heroSource = useHeroSource();
const heroSourceEl = () => heroSource()[MEDIA_VIEWER_HEROSRC];
type State = { type State = {
ref?: HTMLElement; ref?: HTMLElement;
media: mastodon.v1.MediaAttachment; media: mastodon.v1.MediaAttachment;

View file

@ -1,7 +1,23 @@
import { useParams } from "@solidjs/router";
import type { Component } from "solid-js"; import type { Component } from "solid-js";
import Scaffold from "../material/Scaffold";
import TootThread from "./TootThread";
import { AppBar, Toolbar } from "@suid/material";
import { Title } from "../material/typography";
const TootBottomSheet: Component = (props) => { const TootBottomSheet: Component = (props) => {
return <></>; const params = useParams()
return <Scaffold
topbar={
<AppBar position="static">
<Toolbar variant="dense" sx={{paddingTop: "var(--safe-area-inset-top, 0px)"}}>
<Title>A Toot</Title>
</Toolbar>
</AppBar>
}
>
<p>{params.acct}/{params.id}</p>
</Scaffold>;
}; };
export default TootBottomSheet; export default TootBottomSheet;

View file

@ -1,5 +1,5 @@
import type { mastodon } from "masto"; import type { mastodon } from "masto";
import { Show, createResource, createSignal, type Component } from "solid-js"; import { Show, createResource, createSignal, type Component, type Ref } from "solid-js";
import CompactToot from "./CompactToot"; import CompactToot from "./CompactToot";
import { useTimeSource } from "../platform/timesrc"; import { useTimeSource } from "../platform/timesrc";
import RegularToot from "./RegularToot"; import RegularToot from "./RegularToot";
@ -7,6 +7,7 @@ import cardStyle from "../material/cards.module.css";
import { css } from "solid-styled"; import { css } from "solid-styled";
type TootThreadProps = { type TootThreadProps = {
ref?: Ref<HTMLElement>,
status: mastodon.v1.Status; status: mastodon.v1.Status;
client: mastodon.rest.Client; client: mastodon.rest.Client;
expanded?: 0 | 1 | 2; expanded?: 0 | 1 | 2;
@ -70,6 +71,7 @@ const TootThread: Component<TootThreadProps> = (props) => {
return ( return (
<article <article
ref={props.ref}
classList={{ "thread-line": !!inReplyTo(), expanded: expanded() > 0 }} classList={{ "thread-line": !!inReplyTo(), expanded: expanded() > 0 }}
onClick={() => props.onExpandChange?.(nextExpandLevel[expanded()])} onClick={() => props.onExpandChange?.(nextExpandLevel[expanded()])}
> >