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">
<title>Tutu</title>
<link rel="stylesheet" href="/src/App.css" />
<script src="/src/platform/polyfills.ts" type="module"></script>
<script src="/src/index.tsx" type="module"></script>
</head>
<body>

View file

@ -42,7 +42,8 @@
"nanostores": "^0.9.5",
"solid-js": "^1.8.18",
"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"
}

View file

@ -22,6 +22,7 @@ const AccountMastodonOAuth2Callback = lazy(
);
const TimelineHome = lazy(() => import("./timelines/Home.js"));
const Settings = lazy(() => import("./settings/Settings.js"));
const TootBottomSheet = lazy(() => import("./timelines/TootBottomSheet.js"));
const Routing: Component = () => {
return (
@ -29,6 +30,7 @@ const Routing: Component = () => {
<Route path="/" component={TimelineHome}>
<Route path=""></Route>
<Route path="/settings" component={Settings}></Route>
<Route path="/:acct/:id" component={TootBottomSheet}></Route>
</Route>
<Route path={"/accounts"}>
<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 (
<ErrorBoundary

View file

@ -30,4 +30,9 @@
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 { useHeroSignal } from "../platform/anim";
export type BottomSheetProps = {
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) => {
let element: HTMLDialogElement;
let animation: Animation | undefined;
const hero = useHeroSignal(HERO);
createEffect(() => {
if (props.open) {
if (!element.open) {
element.showModal();
animateOpen();
}
} else {
if (element.open) {
if (animation) {
animation.cancel();
}
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 (
<dialog class={styles.bottomSheet} ref={element!}>
{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 = {
[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 function useHeroSource() {
function useHeroSource() {
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 { useTimeline } from "../masto/timelines";
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 { useStore } from "@nanostores/solid";
import { vibrate } from "../platform/hardware";
import PullDownToRefresh from "./PullDownToRefresh";
import { HeroSourceProvider, type HeroSource } from "../platform/anim";
import { useNavigate } from "@solidjs/router";
const TimelinePanel: Component<{
client: mastodon.rest.Client;
name: "home" | "public" | "trends";
prefetch?: boolean;
openFullScreenToot: (
toot: mastodon.v1.Status,
srcElement?: HTMLElement,
) => void;
}> = (props) => {
const [scrollLinked, setScrollLinked] = createSignal<HTMLElement>();
const [
@ -125,18 +134,22 @@ const TimelinePanel: Component<{
>
<For each={timeline}>
{(item, index) => {
let element: HTMLElement | undefined;
return (
<TootThread
ref={element}
status={item}
onBoost={(...args) => onBoost(index(), ...args)}
onBookmark={(...args) => onBookmark(index(), ...args)}
client={props.client}
expanded={item.id === expandedThreadId() ? 1 : 0}
onExpandChange={() =>
setExpandedThreadId(
item.id !== expandedThreadId() ? item.id : undefined,
)
}
onExpandChange={(x) => {
if (item.id !== expandedThreadId()) {
setExpandedThreadId(item.id);
} else if (x === 2){
props.openFullScreenToot(item, element);
}
}}
/>
);
}}
@ -185,7 +198,9 @@ const Home: ParentComponent = (props) => {
const sessions = useSessions();
const client = () => sessions()[0].client;
const [profile] = useAcctProfile(client);
const navigate = useNavigate();
const [heroSrc, setHeroSrc] = createSignal<HeroSource>({});
const [panelOffset, setPanelOffset] = createSignal(0);
const prefetching = () => !settings$().prefetchTootsDisabled;
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`
.tab-panel {
overflow: visible auto;
@ -344,6 +375,7 @@ const Home: ParentComponent = (props) => {
client={client()}
name="home"
prefetch={prefetching()}
openFullScreenToot={openFullScreenToot}
/>
</div>
</div>
@ -353,6 +385,7 @@ const Home: ParentComponent = (props) => {
client={client()}
name="trends"
prefetch={prefetching()}
openFullScreenToot={openFullScreenToot}
/>
</div>
</div>
@ -362,13 +395,16 @@ const Home: ParentComponent = (props) => {
client={client()}
name="public"
prefetch={prefetching()}
openFullScreenToot={openFullScreenToot}
/>
</div>
</div>
<div></div>
</div>
</TimeSourceProvider>
<BottomSheet open={!!child()}>{child()}</BottomSheet>
<HeroSourceProvider value={[heroSrc, setHeroSrc]}>
<BottomSheet open={!!child()}>{child()}</BottomSheet>
</HeroSourceProvider>
</Scaffold>
</>
);

View file

@ -15,8 +15,6 @@ import {
untrack,
} from "solid-js";
import { css } from "solid-styled";
import { useHeroSource } from "../platform/anim";
import { Portal } from "solid-js/web";
import { createStore } from "solid-js/store";
import { IconButton, Toolbar } from "@suid/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) => {
let rootRef: HTMLDialogElement;
const heroSource = useHeroSource();
const heroSourceEl = () => heroSource()[MEDIA_VIEWER_HEROSRC];
type State = {
ref?: HTMLElement;
media: mastodon.v1.MediaAttachment;

View file

@ -1,7 +1,23 @@
import { useParams } from "@solidjs/router";
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) => {
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;

View file

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