diff --git a/.changeset/plenty-chairs-refuse.md b/.changeset/plenty-chairs-refuse.md new file mode 100644 index 000000000000..a3bc2e628b4b --- /dev/null +++ b/.changeset/plenty-chairs-refuse.md @@ -0,0 +1,5 @@ +--- +"live-mobile": minor +--- + +feat: add header and bottom bar animations on webview scroll diff --git a/apps/ledger-live-mobile/src/components/Web3AppWebview/PlatformAPIWebview.tsx b/apps/ledger-live-mobile/src/components/Web3AppWebview/PlatformAPIWebview.tsx index 62d46daf278a..4c00c7df69e3 100644 --- a/apps/ledger-live-mobile/src/components/Web3AppWebview/PlatformAPIWebview.tsx +++ b/apps/ledger-live-mobile/src/components/Web3AppWebview/PlatformAPIWebview.tsx @@ -58,7 +58,7 @@ function renderLoading() { ); } export const PlatformAPIWebview = forwardRef( - ({ manifest, inputs = {}, onStateChange }, ref) => { + ({ manifest, inputs = {}, onStateChange, onScroll }, ref) => { const tracking = useMemo( () => trackingWrapper((eventName: string, properties?: Record | null) => @@ -527,6 +527,8 @@ export const PlatformAPIWebview = forwardRef( return ( ( customHandlers, onStateChange, allowsBackForwardNavigationGestures = true, + onScroll, }, ref, ) => { @@ -49,6 +50,8 @@ export const WalletAPIWebview = forwardRef( return ( ( customHandlers, onStateChange, allowsBackForwardNavigationGestures, + onScroll, }, ref, ) => { @@ -21,6 +22,7 @@ export const Web3AppWebview = forwardRef( return ( ( return ( void; allowsBackForwardNavigationGestures?: boolean; customHandlers?: WalletAPICustomHandlers; + onScroll?: ComponentProps["onScroll"]; }; export type WebviewState = { diff --git a/apps/ledger-live-mobile/src/locales/en/common.json b/apps/ledger-live-mobile/src/locales/en/common.json index 791f715863b4..9b0902be9dfb 100644 --- a/apps/ledger-live-mobile/src/locales/en/common.json +++ b/apps/ledger-live-mobile/src/locales/en/common.json @@ -6844,6 +6844,9 @@ "description": "Discover the best of web3 curated by Ledger" }, "main": { + "manifestsList": { + "title": "Discover" + }, "header": { "title": "Explore web3", "placeholder": "Search or type a URL" diff --git a/apps/ledger-live-mobile/src/newArch/features/Web3Hub/components/ManifestsList/index.tsx b/apps/ledger-live-mobile/src/newArch/features/Web3Hub/components/ManifestsList/index.tsx index a563fdd56c76..f714f8215196 100644 --- a/apps/ledger-live-mobile/src/newArch/features/Web3Hub/components/ManifestsList/index.tsx +++ b/apps/ledger-live-mobile/src/newArch/features/Web3Hub/components/ManifestsList/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from "react"; +import React, { ComponentProps, useCallback, useState } from "react"; import { View } from "react-native"; import Animated from "react-native-reanimated"; import { useTranslation } from "react-i18next"; @@ -18,7 +18,9 @@ type NavigationProp = MainProps["navigation"] | SearchProps["navigation"]; type Props = { navigation: NavigationProp; - onScroll?: FlashListProps["onScroll"]; + onScroll?: ComponentProps["onScroll"]; + title?: string; + pt?: number; pb?: number; }; @@ -38,7 +40,7 @@ const renderItem = ({ return ; }; -export default function ManifestsList({ navigation, onScroll, pb = 0 }: Props) { +export default function ManifestsList({ navigation, onScroll, title, pt = 0, pb = 0 }: Props) { const { t } = useTranslation(); const insets = useSafeAreaInsets(); const [selectedCategory, selectCategory] = useState("all"); @@ -63,12 +65,13 @@ export default function ManifestsList({ navigation, onScroll, pb = 0 }: Props) { - {t("web3hub.manifestsList.title")} + {title ?? t("web3hub.manifestsList.title")} {t("web3hub.manifestsList.description")} diff --git a/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubApp/components/Header/index.tsx b/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubApp/components/Header/index.tsx index e09d1e86259d..24d4d97cb084 100644 --- a/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubApp/components/Header/index.tsx +++ b/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubApp/components/Header/index.tsx @@ -1,13 +1,19 @@ -import React from "react"; +import React, { useContext } from "react"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useTheme } from "@react-navigation/native"; -import { Box, Flex } from "@ledgerhq/native-ui"; +import Animated, { useAnimatedStyle, interpolate, Extrapolation } from "react-native-reanimated"; +import { Flex } from "@ledgerhq/native-ui"; +import { HeaderContext } from "LLM/features/Web3Hub/HeaderContext"; import BackButton from "LLM/features/Web3Hub/components/BackButton"; import TabButton from "LLM/features/Web3Hub/components/TabButton"; import { AppProps } from "LLM/features/Web3Hub/types"; import TextInput from "~/components/TextInput"; const SEARCH_HEIGHT = 60; +export const TOTAL_HEADER_HEIGHT = SEARCH_HEIGHT; + +const ANIMATION_HEIGHT = TOTAL_HEADER_HEIGHT; +const LAYOUT_RANGE = [0, ANIMATION_HEIGHT]; type Props = { navigation: AppProps["navigation"]; @@ -16,26 +22,72 @@ type Props = { export default function Web3HubAppHeader({ navigation }: Props) { const insets = useSafeAreaInsets(); const { colors } = useTheme(); + const { layoutY } = useContext(HeaderContext); + + const heightStyle = useAnimatedStyle(() => { + if (!layoutY) return {}; + + const headerHeight = interpolate( + layoutY.value, + LAYOUT_RANGE, + [TOTAL_HEADER_HEIGHT, TOTAL_HEADER_HEIGHT - ANIMATION_HEIGHT], + Extrapolation.CLAMP, + ); + + return { + backgroundColor: colors.background, + paddingTop: insets.top, + height: headerHeight + insets.top, + }; + }); + + const transformStyle = useAnimatedStyle(() => { + if (!layoutY) return {}; + + return { + // Height necessary for proper transform + height: TOTAL_HEADER_HEIGHT, + transform: [ + { + translateY: interpolate( + layoutY.value, + LAYOUT_RANGE, + [0, -ANIMATION_HEIGHT], + Extrapolation.CLAMP, + ), + }, + ], + }; + }); + + const opacityStyle = useAnimatedStyle(() => { + if (!layoutY) return {}; + + return { + height: TOTAL_HEADER_HEIGHT, + opacity: interpolate(layoutY.value, [0, ANIMATION_HEIGHT], [1, 0], Extrapolation.CLAMP), + }; + }); return ( - - - - - - - - - + + + + + + + + + + + + + ); } diff --git a/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubApp/components/Web3Player/BottomBar.tsx b/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubApp/components/Web3Player/BottomBar.tsx index 59c19ac48fc5..3884e689377f 100644 --- a/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubApp/components/Web3Player/BottomBar.tsx +++ b/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubApp/components/Web3Player/BottomBar.tsx @@ -1,18 +1,27 @@ -import React, { RefObject, useCallback } from "react"; +import React, { RefObject, useCallback, useContext } from "react"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { TouchableOpacity } from "react-native"; -import { useTheme } from "styled-components/native"; +import { useTheme } from "@react-navigation/native"; import { Trans } from "react-i18next"; +import Animated, { useAnimatedStyle, interpolate, Extrapolation } from "react-native-reanimated"; import { Flex, Text } from "@ledgerhq/native-ui"; import { AppManifest } from "@ledgerhq/live-common/wallet-api/types"; import { ArrowLeftMedium, ArrowRightMedium, ReverseMedium } from "@ledgerhq/native-ui/assets/icons"; import { safeGetRefValue, CurrentAccountHistDB } from "@ledgerhq/live-common/wallet-api/react"; import { useDappCurrentAccount } from "@ledgerhq/live-common/wallet-api/useDappLogic"; +import { HeaderContext } from "LLM/features/Web3Hub/HeaderContext"; import { WebviewAPI, WebviewState } from "~/components/Web3AppWebview/types"; import Button from "~/components/Button"; import CircleCurrencyIcon from "~/components/CircleCurrencyIcon"; import { useSelectAccount } from "~/components/Web3AppWebview/helpers"; import { useMaybeAccountName } from "~/reducers/wallet"; +const BAR_HEIGHT = 60; +export const TOTAL_HEADER_HEIGHT = BAR_HEIGHT; + +const ANIMATION_HEIGHT = TOTAL_HEADER_HEIGHT; +const LAYOUT_RANGE = [0, ANIMATION_HEIGHT]; + type BottomBarProps = { manifest: AppManifest; webviewAPIRef: RefObject; @@ -47,6 +56,7 @@ export function BottomBar({ webviewState, currentAccountHistDb, }: BottomBarProps) { + const insets = useSafeAreaInsets(); const { colors } = useTheme(); const { currentAccount } = useDappCurrentAccount(currentAccountHistDb); const shouldDisplaySelectAccount = !!manifest.dapp; @@ -73,51 +83,83 @@ export function BottomBar({ const currentAccountName = useMaybeAccountName(currentAccount); + const { layoutY } = useContext(HeaderContext); + + const heightStyle = useAnimatedStyle(() => { + if (!layoutY) return {}; + + const headerHeight = interpolate( + layoutY.value, + LAYOUT_RANGE, + [TOTAL_HEADER_HEIGHT, TOTAL_HEADER_HEIGHT - ANIMATION_HEIGHT], + Extrapolation.CLAMP, + ); + + return { + backgroundColor: colors.background, + paddingBottom: insets.bottom, + height: headerHeight + insets.bottom, + }; + }); + + const opacityStyle = useAnimatedStyle(() => { + if (!layoutY) return {}; + + return { + height: TOTAL_HEADER_HEIGHT, + opacity: interpolate(layoutY.value, [0, ANIMATION_HEIGHT], [1, 0], Extrapolation.CLAMP), + }; + }); + return ( - - - - - - - - - - + + + + + + + - {shouldDisplaySelectAccount ? ( - - ) : null} - - - - - + + + + {shouldDisplaySelectAccount ? ( + + ) : null} + + + + + + + ); } diff --git a/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubApp/components/Web3Player/index.tsx b/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubApp/components/Web3Player/index.tsx index 51eb7ff6ed66..52ac30c92f42 100644 --- a/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubApp/components/Web3Player/index.tsx +++ b/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubApp/components/Web3Player/index.tsx @@ -1,38 +1,27 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { StyleSheet, SafeAreaView, BackHandler, Platform } from "react-native"; -import { useNavigation } from "@react-navigation/native"; -import { Flex } from "@ledgerhq/native-ui"; +import React, { ComponentProps, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { StyleSheet, View, BackHandler, Platform } from "react-native"; import { CurrentAccountHistDB, safeGetRefValue } from "@ledgerhq/live-common/wallet-api/react"; import { handlers as loggerHandlers } from "@ledgerhq/live-common/wallet-api/CustomLogger/server"; import { AppManifest, WalletAPICustomHandlers } from "@ledgerhq/live-common/wallet-api/types"; import { WebviewAPI, WebviewState } from "~/components/Web3AppWebview/types"; import { Web3AppWebview } from "~/components/Web3AppWebview"; -import { - RootNavigationComposite, - StackNavigatorNavigation, -} from "~/components/RootNavigator/types/helpers"; -import { BaseNavigatorStackParamList } from "~/components/RootNavigator/types/BaseNavigator"; -import HeaderTitle from "~/components/HeaderTitle"; import { initialWebviewState } from "~/components/Web3AppWebview/helpers"; import { usePTXCustomHandlers } from "~/components/WebPTXPlayer/CustomHandlers"; import { useCurrentAccountHistDB } from "~/screens/Platform/v2/hooks"; -import { RightHeader } from "./RightHeader"; import { BottomBar } from "./BottomBar"; import { InfoPanel } from "./InfoPanel"; type Props = { manifest: AppManifest; inputs?: Record; + onScroll?: ComponentProps["onScroll"]; }; -const WebPlatformPlayer = ({ manifest, inputs }: Props) => { +const WebPlatformPlayer = ({ manifest, inputs, onScroll }: Props) => { const webviewAPIRef = useRef(null); const [webviewState, setWebviewState] = useState(initialWebviewState); const [isInfoPanelOpened, setIsInfoPanelOpened] = useState(false); - const navigation = - useNavigation>>(); - const currentAccountHistDb: CurrentAccountHistDB = useCurrentAccountHistDB(); const handleHardwareBackPress = useCallback(() => { @@ -57,27 +46,6 @@ const WebPlatformPlayer = ({ manifest, inputs }: Props) => { } }, [handleHardwareBackPress]); - useEffect(() => { - navigation.setOptions({ - headerTitleAlign: "left", - headerLeft: () => null, - headerTitleContainerStyle: { marginHorizontal: 0 }, - headerTitle: () => ( - - {manifest.homepageUrl} - - ), - headerRight: () => ( - setIsInfoPanelOpened(true)} - /> - ), - headerShown: true, - }); - }, [manifest, navigation, webviewState]); - const customPTXHandlers = usePTXCustomHandlers(manifest); const customHandlers = useMemo(() => { @@ -88,9 +56,10 @@ const WebPlatformPlayer = ({ manifest, inputs }: Props) => { }, [customPTXHandlers]); return ( - + { isOpened={isInfoPanelOpened} setIsOpened={setIsInfoPanelOpened} /> - + ); }; diff --git a/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubApp/index.tsx b/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubApp/index.tsx index ca7663c38efe..d9c9754d90f4 100644 --- a/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubApp/index.tsx +++ b/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubApp/index.tsx @@ -1,7 +1,9 @@ -import React, { useMemo } from "react"; +import React, { ComponentProps, useCallback, useContext, useMemo, useRef } from "react"; +// import { useAnimatedScrollHandler } from "react-native-reanimated"; import { useTheme } from "styled-components/native"; import { Flex, InfiniteLoader } from "@ledgerhq/native-ui"; import type { AppProps } from "LLM/features/Web3Hub/types"; +import { HeaderContext } from "LLM/features/Web3Hub/HeaderContext"; import WebPlatformPlayer from "./components/Web3Player"; import GenericErrorView from "~/components/GenericErrorView"; import { useLocale } from "~/context/Locale"; @@ -9,11 +11,65 @@ import useWeb3HubAppViewModel from "./useWeb3HubAppViewModel"; const appManifestNotFoundError = new Error("App not found"); +const clamp = (value: number, lowerBound: number, upperBound: number) => { + "worklet"; + return Math.min(Math.max(lowerBound, value), upperBound); +}; + +type NoOptionals = { + [K in keyof T]-?: T[K]; +}; + +const initialTimeoutRef = { + prevY: 0, + prevLayoutY: 0, +}; + // TODO local manifests ? export default function Web3HubApp({ route }: AppProps) { const { manifestId, queryParams } = route.params; const { theme } = useTheme(); const { locale } = useLocale(); + const { layoutY } = useContext(HeaderContext); + + // const scrollHandler = useAnimatedScrollHandler<{ prevY: number; prevLayoutY: number }>({ + // onScroll: (event, ctx) => { + // if (!layoutY) return; + + // const diff = event.contentOffset.y - ctx.prevY; + + // layoutY.value = clamp(ctx.prevLayoutY + diff, 0, 60); + // }, + // onBeginDrag: (event, ctx) => { + // if (layoutY) { + // ctx.prevLayoutY = layoutY.value; + // } + // ctx.prevY = event.contentOffset.y; + // }, + // }); + + // Trick until we can properly use reanimated with the webview + const timeoutRef = useRef<{ timeout?: NodeJS.Timeout; prevY: number; prevLayoutY: number }>( + initialTimeoutRef, + ); + + const onScroll = useCallback( + (event: Parameters>["onScroll"]>[0]) => { + if (!layoutY) return; + clearTimeout(timeoutRef.current.timeout); + + const currentY = event.nativeEvent.contentOffset.y; + + const diff = currentY - timeoutRef.current.prevY; + layoutY.value = clamp(timeoutRef.current.prevLayoutY + diff, 0, 60); + + timeoutRef.current.timeout = setTimeout(() => { + timeoutRef.current.prevY = currentY; + timeoutRef.current.prevLayoutY = layoutY.value; + }, 100); + }, + [layoutY], + ); const { manifest, isLoading } = useWeb3HubAppViewModel(manifestId); @@ -26,7 +82,7 @@ export default function Web3HubApp({ route }: AppProps) { }, [locale, queryParams, theme]); return manifest ? ( - + ) : ( {isLoading ? : } diff --git a/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubMain/components/Header/index.tsx b/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubMain/components/Header/index.tsx index 132914011b0b..b2adb6857ff1 100644 --- a/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubMain/components/Header/index.tsx +++ b/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubMain/components/Header/index.tsx @@ -15,10 +15,10 @@ import { NavigatorName, ScreenName } from "~/const"; const TITLE_HEIGHT = 50; const SEARCH_HEIGHT = 60; -const TOTAL_HEADER_HEIGHT = TITLE_HEIGHT + SEARCH_HEIGHT; +export const TOTAL_HEADER_HEIGHT = TITLE_HEIGHT + SEARCH_HEIGHT; -const ANIMATION_HEIGHT = TITLE_HEIGHT - 5; -const LAYOUT_RANGE = [0, 35]; +export const ANIMATION_HEIGHT = TITLE_HEIGHT - 5; +const LAYOUT_RANGE = [0, ANIMATION_HEIGHT]; type Props = { title?: string; @@ -31,19 +31,26 @@ export default function Web3HubMainHeader({ title, navigation }: Props) { const insets = useSafeAreaInsets(); const { colors } = useTheme(); + // Using a const here to avoid serializing colors in the worklet + const backgroundColor = colors.background; + const heightStyle = useAnimatedStyle(() => { if (!layoutY) return {}; + const headerHeight = interpolate( + layoutY.value, + LAYOUT_RANGE, + [TOTAL_HEADER_HEIGHT, TOTAL_HEADER_HEIGHT - ANIMATION_HEIGHT], + Extrapolation.CLAMP, + ); + return { - backgroundColor: colors.background, + zIndex: 1, + position: "absolute", + width: "100%", + backgroundColor: backgroundColor, paddingTop: insets.top, - height: - interpolate( - layoutY.value, - LAYOUT_RANGE, - [TOTAL_HEADER_HEIGHT, TOTAL_HEADER_HEIGHT - ANIMATION_HEIGHT], - Extrapolation.CLAMP, - ) + insets.top, + height: headerHeight + insets.top, }; }); @@ -71,7 +78,7 @@ export default function Web3HubMainHeader({ title, navigation }: Props) { return { height: TITLE_HEIGHT, - opacity: interpolate(layoutY.value, [0, 35], [1, 0], Extrapolation.CLAMP), + opacity: interpolate(layoutY.value, [0, ANIMATION_HEIGHT], [1, 0], Extrapolation.CLAMP), }; }); diff --git a/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubMain/index.tsx b/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubMain/index.tsx index 922c14d89ba1..f93121f46aa7 100644 --- a/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubMain/index.tsx +++ b/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubMain/index.tsx @@ -1,19 +1,38 @@ import React, { useContext } from "react"; +import { useTranslation } from "react-i18next"; import { View } from "react-native"; import { useAnimatedScrollHandler } from "react-native-reanimated"; import type { MainProps } from "LLM/features/Web3Hub/types"; import { HeaderContext } from "LLM/features/Web3Hub/HeaderContext"; import ManifestsList from "LLM/features/Web3Hub/components/ManifestsList"; import { MAIN_BUTTON_BOTTOM, MAIN_BUTTON_SIZE } from "~/components/TabBar/shared"; +import { ANIMATION_HEIGHT, TOTAL_HEADER_HEIGHT } from "./components/Header"; const PADDING_BOTTOM = MAIN_BUTTON_SIZE + MAIN_BUTTON_BOTTOM; +const clamp = (value: number, lowerBound: number, upperBound: number) => { + "worklet"; + return Math.min(Math.max(lowerBound, value), upperBound); +}; + export default function Web3HubMain({ navigation }: MainProps) { + const { t } = useTranslation(); const { layoutY } = useContext(HeaderContext); - const scrollHandler = useAnimatedScrollHandler(event => { - if (!layoutY) return; - layoutY.value = event.contentOffset.y; + const scrollHandler = useAnimatedScrollHandler<{ prevY: number; prevLayoutY: number }>({ + onScroll: (event, ctx) => { + if (!layoutY) return; + + const diff = event.contentOffset.y - ctx.prevY; + + layoutY.value = clamp(ctx.prevLayoutY + diff, 0, ANIMATION_HEIGHT); + }, + onBeginDrag: (event, ctx) => { + if (layoutY) { + ctx.prevLayoutY = layoutY.value; + } + ctx.prevY = event.contentOffset.y; + }, }); return ( @@ -23,8 +42,10 @@ export default function Web3HubMain({ navigation }: MainProps) { }} > diff --git a/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubSearch/components/Header/index.tsx b/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubSearch/components/Header/index.tsx index 1845a02745e1..94163fe29857 100644 --- a/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubSearch/components/Header/index.tsx +++ b/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubSearch/components/Header/index.tsx @@ -1,15 +1,21 @@ -import React, { useEffect, useState } from "react"; +import React, { useContext, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useTheme } from "@react-navigation/native"; -import { Box, Flex } from "@ledgerhq/native-ui"; +import Animated, { useAnimatedStyle, interpolate, Extrapolation } from "react-native-reanimated"; +import { Flex } from "@ledgerhq/native-ui"; import { useDebounce } from "@ledgerhq/live-common/hooks/useDebounce"; +import { HeaderContext } from "LLM/features/Web3Hub/HeaderContext"; import BackButton from "LLM/features/Web3Hub/components/BackButton"; import TabButton from "LLM/features/Web3Hub/components/TabButton"; import { SearchProps } from "LLM/features/Web3Hub/types"; import TextInput from "~/components/TextInput"; const SEARCH_HEIGHT = 60; +export const TOTAL_HEADER_HEIGHT = SEARCH_HEIGHT; + +const ANIMATION_HEIGHT = TOTAL_HEADER_HEIGHT; +const LAYOUT_RANGE = [0, ANIMATION_HEIGHT]; type Props = { navigation: SearchProps["navigation"]; @@ -19,6 +25,7 @@ type Props = { export default function Web3HubSearchHeader({ navigation, onSearch }: Props) { const { colors } = useTheme(); const { t } = useTranslation(); + const { layoutY } = useContext(HeaderContext); const insets = useSafeAreaInsets(); const [search, setSearch] = useState(""); const debouncedSearch = useDebounce(search, 400); @@ -27,28 +34,76 @@ export default function Web3HubSearchHeader({ navigation, onSearch }: Props) { onSearch(debouncedSearch); }, [debouncedSearch, onSearch]); + const heightStyle = useAnimatedStyle(() => { + if (!layoutY) return {}; + + const headerHeight = interpolate( + layoutY.value, + LAYOUT_RANGE, + [TOTAL_HEADER_HEIGHT, TOTAL_HEADER_HEIGHT - ANIMATION_HEIGHT], + Extrapolation.CLAMP, + ); + + return { + zIndex: 1, + position: "absolute", + width: "100%", + backgroundColor: colors.background, + paddingTop: insets.top, + height: headerHeight + insets.top, + }; + }); + + const transformStyle = useAnimatedStyle(() => { + if (!layoutY) return {}; + + return { + // Height necessary for proper transform + height: TOTAL_HEADER_HEIGHT, + transform: [ + { + translateY: interpolate( + layoutY.value, + LAYOUT_RANGE, + [0, -ANIMATION_HEIGHT], + Extrapolation.CLAMP, + ), + }, + ], + }; + }); + + const opacityStyle = useAnimatedStyle(() => { + if (!layoutY) return {}; + + return { + height: TOTAL_HEADER_HEIGHT, + opacity: interpolate(layoutY.value, [0, ANIMATION_HEIGHT], [1, 0], Extrapolation.CLAMP), + }; + }); + return ( - - - - - console.log("onSubmitEditing: ", text)} - /> - - - - + + + + + + + console.log("onSubmitEditing: ", text)} + /> + + + + + + ); } diff --git a/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubSearch/components/SearchList/index.tsx b/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubSearch/components/SearchList/index.tsx index a24b2d5530c1..ccd58e418063 100644 --- a/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubSearch/components/SearchList/index.tsx +++ b/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubSearch/components/SearchList/index.tsx @@ -1,6 +1,7 @@ import React, { useCallback } from "react"; +import Animated from "react-native-reanimated"; import { useTranslation } from "react-i18next"; -import { FlashList } from "@shopify/flash-list"; +import { FlashList, FlashListProps } from "@shopify/flash-list"; import { Box, Text } from "@ledgerhq/native-ui"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppManifest } from "@ledgerhq/live-common/wallet-api/types"; @@ -13,6 +14,15 @@ import SearchItem from "./SearchItem"; type NavigationProp = SearchProps["navigation"]; +type Props = { + navigation: NavigationProp; + search: string; + onScroll?: FlashListProps["onScroll"]; + pt?: number; +}; + +const AnimatedFlashList = Animated.createAnimatedComponent>(FlashList); + const keyExtractor = (item: AppManifest) => item.id; const noop = () => {}; @@ -27,13 +37,7 @@ const renderItem = ({ return ; }; -export default function SearchList({ - navigation, - search, -}: { - navigation: NavigationProp; - search: string; -}) { +export default function SearchList({ navigation, search, onScroll, pt = 0 }: Props) { const { t } = useTranslation(); const insets = useSafeAreaInsets(); const { data, isLoading, onEndReached } = useSearchListViewModel(search); @@ -52,8 +56,9 @@ export default function SearchList({ return ( <> - ); diff --git a/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubSearch/index.tsx b/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubSearch/index.tsx index 2355fb3c8ab2..6205cd46c562 100644 --- a/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubSearch/index.tsx +++ b/apps/ledger-live-mobile/src/newArch/features/Web3Hub/screens/Web3HubSearch/index.tsx @@ -1,12 +1,35 @@ import React, { useContext } from "react"; import { View } from "react-native"; +import { useAnimatedScrollHandler } from "react-native-reanimated"; import type { SearchProps } from "LLM/features/Web3Hub/types"; import { HeaderContext } from "LLM/features/Web3Hub/HeaderContext"; import ManifestsList from "LLM/features/Web3Hub/components/ManifestsList"; import SearchList from "./components/SearchList"; +import { TOTAL_HEADER_HEIGHT } from "./components/Header"; + +const clamp = (value: number, lowerBound: number, upperBound: number) => { + "worklet"; + return Math.min(Math.max(lowerBound, value), upperBound); +}; export default function Web3HubSearch({ navigation }: SearchProps) { - const { search } = useContext(HeaderContext); + const { search, layoutY } = useContext(HeaderContext); + + const scrollHandler = useAnimatedScrollHandler<{ prevY: number; prevLayoutY: number }>({ + onScroll: (event, ctx) => { + if (!layoutY) return; + + const diff = event.contentOffset.y - ctx.prevY; + + layoutY.value = clamp(ctx.prevLayoutY + diff, 0, TOTAL_HEADER_HEIGHT); + }, + onBeginDrag: (event, ctx) => { + if (layoutY) { + ctx.prevLayoutY = layoutY.value; + } + ctx.prevY = event.contentOffset.y; + }, + }); return ( {search ? ( - + ) : ( - + )} );