From b3d5b9986211bdade05edde0ebed04c2e56038ad Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Wed, 11 Feb 2026 19:06:38 +0100 Subject: [PATCH] Navbar scroll --- front/bun.lock | 4 +- front/package.json | 2 +- front/src/app/(app)/_layout.tsx | 13 -- front/src/components/entries/entry-line.tsx | 2 +- front/src/components/items/item-grid.tsx | 2 +- front/src/primitives/image-background.tsx | 8 +- front/src/providers/account-provider.tsx | 1 - front/src/query/fetch-infinite.tsx | 32 ++--- front/src/ui/details/header.tsx | 132 +++++++++--------- front/src/ui/details/movie.tsx | 23 +++- front/src/ui/details/serie.tsx | 32 ++++- front/src/ui/home/header.tsx | 4 +- front/src/ui/home/index.tsx | 88 +++++++----- front/src/ui/navbar.tsx | 142 +++++++++----------- 14 files changed, 254 insertions(+), 231 deletions(-) diff --git a/front/bun.lock b/front/bun.lock index 6aadafcf..b5c00cc0 100644 --- a/front/bun.lock +++ b/front/bun.lock @@ -10,7 +10,7 @@ "@expo-google-fonts/sora": "^0.4.2", "@expo/html-elements": "^0.13.7", "@gorhom/portal": "^1.0.14", - "@legendapp/list": "^3.0.0-beta.31", + "@legendapp/list": "zoriya/legend-list#build", "@material-symbols/svg-400": "^0.40.2", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-select": "^2.2.6", @@ -442,7 +442,7 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@legendapp/list": ["@legendapp/list@3.0.0-beta.31", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*" } }, "sha512-9KpPvwK/14QTJZVbEgDxjzRwcgSf1gkYSxw/d8GE03uasjnzCHe/fm4qrqpobDLYYt90wuemhjkE3YE18XMKEg=="], + "@legendapp/list": ["@legendapp/list@github:zoriya/legend-list#7acb558", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*" } }, "zoriya-legend-list-7acb558"], "@material-symbols/svg-400": ["@material-symbols/svg-400@0.40.2", "", {}, "sha512-e2yEgZW/OveVT1sGaZW1kkRWTPVghjsJYWy+vIea3q08Fv2o7FCYv23PESMyr5D4AaAXdM5dKWkF1e6yIm4swA=="], diff --git a/front/package.json b/front/package.json index ddfd8ba3..be74aa05 100644 --- a/front/package.json +++ b/front/package.json @@ -20,7 +20,7 @@ "@expo-google-fonts/sora": "^0.4.2", "@expo/html-elements": "^0.13.7", "@gorhom/portal": "^1.0.14", - "@legendapp/list": "^3.0.0-beta.31", + "@legendapp/list": "zoriya/legend-list#build", "@material-symbols/svg-400": "^0.40.2", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-select": "^2.2.6", diff --git a/front/src/app/(app)/_layout.tsx b/front/src/app/(app)/_layout.tsx index 70923056..d6fcb47b 100644 --- a/front/src/app/(app)/_layout.tsx +++ b/front/src/app/(app)/_layout.tsx @@ -1,4 +1,3 @@ -import { getFocusedRouteNameFromRoute } from "@react-navigation/native"; import { Stack } from "expo-router"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useCSSVariable, useResolveClassNames } from "uniwind"; @@ -26,18 +25,6 @@ export default function Layout() { headerTintColor: color as string, }} > - { - if (getFocusedRouteNameFromRoute(route) === "index") { - return { - headerTransparent: true, - headerStyle: { backgroundColor: undefined }, - }; - } - return {}; - }} - /> setMoreOpened(true)} className={cn( - "group flex-row items-center", + "group flex-row items-center p-1", href === null && "opacity-50", className, )} diff --git a/front/src/components/items/item-grid.tsx b/front/src/components/items/item-grid.tsx index e5f631a6..99abe57f 100644 --- a/front/src/components/items/item-grid.tsx +++ b/front/src/components/items/item-grid.tsx @@ -63,7 +63,7 @@ export const ItemGrid = ({ href={moreOpened ? undefined : href} onLongPress={() => setMoreOpened(true)} className={cn( - "group items-center outline-0", + "group items-center p-1 outline-0", horizontal && "h-full w-[200px]", className, )} diff --git a/front/src/primitives/image-background.tsx b/front/src/primitives/image-background.tsx index def8af8e..ee32340c 100644 --- a/front/src/primitives/image-background.tsx +++ b/front/src/primitives/image-background.tsx @@ -1,4 +1,7 @@ -import { ImageBackground as EImageBackground } from "expo-image"; +import { + ImageBackground as EImageBackground, + type ImageBackgroundProps, +} from "expo-image"; import type { ComponentProps, ReactNode } from "react"; import type { ImageStyle } from "react-native"; import { Platform, View } from "react-native"; @@ -25,8 +28,7 @@ export const ImageBackground = ({ alt?: string; style?: ImageStyle; children: ReactNode; - className?: string; -}) => { +} & Partial) => { const { apiUrl, authToken } = useToken(); if (!src) { diff --git a/front/src/providers/account-provider.tsx b/front/src/providers/account-provider.tsx index 602e3cf6..752088a7 100644 --- a/front/src/providers/account-provider.tsx +++ b/front/src/providers/account-provider.tsx @@ -37,7 +37,6 @@ export const AccountProvider = ({ children }: { children: ReactNode }) => { useEffect(() => { if (!ret.apiUrl) { setTimeout(() => { - console.log("go to login"); router.replace("/login"); }, 0); } diff --git a/front/src/query/fetch-infinite.tsx b/front/src/query/fetch-infinite.tsx index 56cb1ed8..748fee36 100644 --- a/front/src/query/fetch-infinite.tsx +++ b/front/src/query/fetch-infinite.tsx @@ -1,7 +1,7 @@ -import { LegendList as RLegendList } from "@legendapp/list"; +import type { LegendListProps } from "@legendapp/list"; +import { AnimatedLegendList } from "@legendapp/list/reanimated"; import { type ComponentType, type ReactElement, useRef } from "react"; import type { ViewStyle } from "react-native"; -import { withUniwind } from "uniwind"; import { type Breakpoint, HR, useBreakpointMap } from "~/primitives"; import { type QueryIdentifier, useInfiniteFetch } from "./query"; @@ -12,8 +12,6 @@ export type Layout = { layout: "grid" | "horizontal" | "vertical"; }; -const LegendList = withUniwind(RLegendList) as typeof RLegendList; - export const InfiniteFetch = ({ query, placeholderCount = 4, @@ -29,8 +27,6 @@ export const InfiniteFetch = ({ Header, fetchMore = true, contentContainerStyle, - contentContainerClassName, - className, ...props }: { query: QueryIdentifier; @@ -40,6 +36,7 @@ export const InfiniteFetch = ({ getItemType?: (item: Data, index: number) => Type; getItemSizeMult?: (item: Data, index: number, type: Type) => number; getStickyIndices?: (items: Data[]) => number[]; + stickyHeaderConfig?: LegendListProps["stickyHeaderConfig"]; Render: (props: { item: Data; index: number }) => ReactElement | null; Loader: (props: { index: number }) => ReactElement | null; Empty?: JSX.Element; @@ -48,18 +45,13 @@ export const InfiniteFetch = ({ Header?: ComponentType<{ children: JSX.Element }> | ReactElement; fetchMore?: boolean; contentContainerStyle?: ViewStyle; - contentContainerClassName?: string; - className?: string; + onScroll?: LegendListProps["onScroll"]; + scrollEventThrottle?: LegendListProps["scrollEventThrottle"]; }): JSX.Element | null => { const { numColumns, size, gap } = useBreakpointMap(layout); const oldItems = useRef(undefined); - let { - items, - fetchNextPage, - isFetching, - refetch, - isRefetching, - } = useInfiniteFetch(query); + let { items, fetchNextPage, isFetching, refetch, isRefetching } = + useInfiniteFetch(query); if (incremental && items) oldItems.current = items; if (incremental) items ??= oldItems.current; @@ -74,16 +66,15 @@ export const InfiniteFetch = ({ isFetching || !items ? [...(items || []), ...placeholders] : items; return ( - getItemSizeMult(item, idx, type as Type) * size + ? (item, idx, type) => getItemSizeMult(item, idx, type as Type) * size : undefined } renderItem={({ item, index }) => @@ -101,10 +92,13 @@ export const InfiniteFetch = ({ Divider === true ? HR : (Divider as any) || undefined } ListEmptyComponent={Empty} + showsHorizontalScrollIndicator={false} + showsVerticalScrollIndicator={false} contentContainerStyle={{ ...contentContainerStyle, gap, - marginHorizontal: numColumns > 1 ? gap : 0, + marginLeft: numColumns > 1 ? gap : 0, + marginRight: numColumns > 1 ? gap : 0, }} {...props} /> diff --git a/front/src/ui/details/header.tsx b/front/src/ui/details/header.tsx index 37109d83..248ab9ba 100644 --- a/front/src/ui/details/header.tsx +++ b/front/src/ui/details/header.tsx @@ -6,7 +6,7 @@ import Theaters from "@material-symbols/svg-400/rounded/theaters-fill.svg"; import { Stack } from "expo-router"; import { Fragment } from "react"; import { useTranslation } from "react-i18next"; -import { View } from "react-native"; +import { View, ViewProps } from "react-native"; import { WatchListInfo } from "~/components/items/watchlist-info"; import { Rating } from "~/components/rating"; import { @@ -380,78 +380,76 @@ Description.Loader = ({ ...props }: object) => { export const Header = ({ kind, slug, + onImageLayout, }: { kind: "movie" | "serie"; slug: string; + onImageLayout?: ViewProps["onLayout"]; }) => { return ( - <> - - ( - - - - - - - + ( + + + + + + + - {/* {type === "show" && ( */} - {/* */} - {/* )} */} - - )} - Loader={() => ( - - - - - - )} - /> - + {/* {type === "show" && ( */} + {/* */} + {/* )} */} + + )} + Loader={() => ( + + + + + + )} + /> ); }; diff --git a/front/src/ui/details/movie.tsx b/front/src/ui/details/movie.tsx index c46f25b5..eb398ab7 100644 --- a/front/src/ui/details/movie.tsx +++ b/front/src/ui/details/movie.tsx @@ -1,15 +1,30 @@ -import { ScrollView } from "react-native"; +import { useState } from "react"; +import Animated from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useQueryState } from "~/utils"; +import { useScrollNavbar } from "../navbar"; import { Header } from "./header"; export const MovieDetails = () => { const [slug] = useQueryState("slug", undefined!); const insets = useSafeAreaInsets(); + const [imageHeight, setHeight] = useState(300); + const { scrollHandler, headerProps } = useScrollNavbar({ imageHeight }); return ( - -
- + <> + + +
setHeight(e.nativeEvent.layout.height)} + /> + + ); }; diff --git a/front/src/ui/details/serie.tsx b/front/src/ui/details/serie.tsx index bb28b017..8cf4079e 100644 --- a/front/src/ui/details/serie.tsx +++ b/front/src/ui/details/serie.tsx @@ -1,4 +1,4 @@ -import type { ComponentProps } from "react"; +import { useState, type ComponentProps } from "react"; import { useTranslation } from "react-i18next"; import { View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -10,6 +10,9 @@ import { Fetch } from "~/query"; import { useQueryState } from "~/utils"; import { Header } from "./header"; import { EntryList } from "./season"; +import { useScrollNavbar } from "../navbar"; +import Animated from "react-native-reanimated"; +import { ViewProps } from "react-native"; export const SvgWave = (props: ComponentProps) => { // aspect-[width/height]: width/height of the svg @@ -54,12 +57,16 @@ NextUp.Loader = () => { ); }; -const SerieHeader = () => { - const [slug] = useQueryState("slug", undefined!); - +const SerieHeader = ({ + slug, + onImageLayout, +}: { + slug: string; + onImageLayout?: ViewProps["onLayout"]; +}) => { return ( -
+
{ const [slug] = useQueryState("slug", undefined!); const [season] = useQueryState("season", undefined!); const insets = useSafeAreaInsets(); + const [imageHeight, setHeight] = useState(300); + const { scrollHandler, headerProps, headerHeight } = useScrollNavbar({ + imageHeight, + }); return ( + ( + setHeight(e.nativeEvent.layout.height)} + /> + )} contentContainerStyle={{ paddingBottom: insets.bottom }} + onScroll={scrollHandler} + scrollEventThrottle={16} + stickyHeaderConfig={{ offset: headerHeight }} /> ); diff --git a/front/src/ui/home/header.tsx b/front/src/ui/home/header.tsx index f5a0fd35..0c9efeb1 100644 --- a/front/src/ui/home/header.tsx +++ b/front/src/ui/home/header.tsx @@ -1,6 +1,7 @@ import Info from "@material-symbols/svg-400/rounded/info.svg"; import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg"; import { LinearGradient } from "expo-linear-gradient"; +import { ComponentProps } from "react"; import { useTranslation } from "react-i18next"; import { View } from "react-native"; import { min, percent, px, rem, vh } from "yoshiki/native"; @@ -37,8 +38,7 @@ export const Header = ({ tagline: string | null; link: string | null; infoLink: string; - className?: string; -}) => { +} & Partial>) => { const { t } = useTranslation(); return ( diff --git a/front/src/ui/home/index.tsx b/front/src/ui/home/index.tsx index 86e89eff..9e298dc3 100644 --- a/front/src/ui/home/index.tsx +++ b/front/src/ui/home/index.tsx @@ -1,7 +1,10 @@ -import { RefreshControl, ScrollView } from "react-native"; +import { useState } from "react"; +import { RefreshControl } from "react-native"; +import Animated from "react-native-reanimated"; import { Genre } from "~/models"; import { Fetch, useRefresh } from "~/query"; import { shuffle } from "~/utils"; +import { useScrollNavbar } from "../navbar"; import { GenreGrid } from "./genre"; import { Header } from "./header"; import { NewsList } from "./news"; @@ -12,46 +15,61 @@ import { WatchlistList } from "./watchlist"; export const HomePage = () => { const genres = shuffle(Object.values(Genre.enum)); const [isRefreshing, refresh] = useRefresh(HomePage.queries(genres)); + const [imageHeight, setHeight] = useState(300); + const { scrollHandler, headerProps } = useScrollNavbar({ + imageHeight, + tab: true, + }); return ( - - } - > - ( -
+ + - )} - Loader={Header.Loader} - /> - - - {genres - .filter((_, i) => i < 2) - .map((x) => ( - - ))} - - {genres - .filter((_, i) => i >= 2 && i < 6) - .map((x) => ( - - ))} - - {/* + } + > + ( +
setHeight(info.nativeEvent.layout.height)} + /> + )} + Loader={Header.Loader} + /> + + + {genres + .filter((_, i) => i < 2) + .map((x) => ( + + ))} + + {genres + .filter((_, i) => i >= 2 && i < 6) + .map((x) => ( + + ))} + + {/* TODO: Lazy load those items {randomItems.filter((_, i) => i >= 6).map((x) => )} */} - + + ); }; diff --git a/front/src/ui/navbar.tsx b/front/src/ui/navbar.tsx index 8d054dfe..889a09e7 100644 --- a/front/src/ui/navbar.tsx +++ b/front/src/ui/navbar.tsx @@ -4,7 +4,12 @@ import Login from "@material-symbols/svg-400/rounded/login.svg"; import Logout from "@material-symbols/svg-400/rounded/logout.svg"; import Search from "@material-symbols/svg-400/rounded/search-fill.svg"; import Settings from "@material-symbols/svg-400/rounded/settings.svg"; -import { useGlobalSearchParams, usePathname, useRouter } from "expo-router"; +import { + useGlobalSearchParams, + useNavigation, + usePathname, + useRouter, +} from "expo-router"; import KyooLongLogo from "public/icon-long.svg"; import { type ComponentProps, @@ -12,6 +17,8 @@ import { useEffect, useRef, useState, + useLayoutEffect, + useCallback, } from "react"; import { useTranslation } from "react-i18next"; import { @@ -20,6 +27,7 @@ import { type TextInput, type TextInputProps, View, + Animated, } from "react-native"; import { A, @@ -35,6 +43,15 @@ import { import { useAccount, useAccounts } from "~/providers/account-context"; import { logout } from "~/ui/login/logic"; import { cn } from "~/utils"; +import { + interpolate, + useAnimatedScrollHandler, + useAnimatedStyle, + useSharedValue, +} from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useIsFocused } from "@react-navigation/native"; +import { useCSSVariable } from "uniwind"; export const NavbarTitle = ({ className, @@ -190,78 +207,51 @@ export const NavbarRight = () => { ); }; -// export const Navbar = ({ -// left, -// right, -// background, -// ...props -// }: { -// left?: ReactElement | null; -// right?: ReactElement | null; -// background?: ReactElement; -// } & Stylable) => { -// const { css } = useYoshiki(); -// const { t } = useTranslation(); -// -// return ( -//
theme.accent, -// paddingX: ts(2), -// height: { xs: 48, sm: 64 }, -// flexDirection: "row", -// justifyContent: { xs: "space-between", sm: "flex-start" }, -// alignItems: "center", -// shadowColor: "#000", -// shadowOffset: { -// width: 0, -// height: 4, -// }, -// shadowOpacity: 0.3, -// shadowRadius: 4.65, -// elevation: 8, -// zIndex: 1, -// }, -// props, -// )} -// > -// {background} -// -// {left !== undefined ? ( -// left -// ) : ( -// <> -// -// theme.contrast, -// })} -// > -// {t("navbar.browse")} -// -// -// )} -// -// -// {right !== undefined ? right : } -//
-// ); -// }; +export const useScrollNavbar = ({ + imageHeight, + tab = false, +}: { + imageHeight: number; + tab?: boolean; +}) => { + const insets = useSafeAreaInsets(); + const height = insets.top + (Platform.OS === "ios" ? 44 : 56); + + const scrollY = useSharedValue(0); + const scrollHandler = useAnimatedScrollHandler((event) => { + scrollY.value = event.contentOffset.y; + }); + const opacity = useAnimatedStyle( + () => ({ + opacity: interpolate(scrollY.value, [0, imageHeight - height], [0, 1]), + }), + [imageHeight, height], + ); + + const nav = useNavigation(); + const focused = useIsFocused(); + const accent = useCSSVariable("--color-accent"); + useLayoutEffect(() => { + const n = tab ? nav.getParent() : nav; + if (focused) { + n?.setOptions({ + headerTransparent: true, + headerStyle: { backgroundColor: "transparent" }, + }); + } + return () => + n?.setOptions({ + headerTransparent: false, + headerStyle: { backgroundColor: accent as string }, + }); + }, [nav, tab, focused, accent]); + + return { + scrollHandler, + headerProps: { + className: cn("absolute z-10 w-full bg-accent"), + style: [{ height }, opacity], + }, + headerHeight: height, + }; +};