diff --git a/packages/core-mobile/app/new/common/components/ListScreenV2.tsx b/packages/core-mobile/app/new/common/components/ListScreenV2.tsx new file mode 100644 index 0000000000..2872831ad1 --- /dev/null +++ b/packages/core-mobile/app/new/common/components/ListScreenV2.tsx @@ -0,0 +1,567 @@ +import { + NavigationTitleHeader, + Separator, + SPRING_LINEAR_TRANSITION, + Text +} from '@avalabs/k2-alpine' +import { BlurViewWithFallback } from '@avalabs/k2-alpine/src/components/BlurViewWithFallback/BlurViewWithFallback' +import { useHeaderHeight } from '@react-navigation/elements' +import { FlashList, FlashListProps } from '@shopify/flash-list' +import { useFadingHeaderNavigation } from 'common/hooks/useFadingHeaderNavigation' +import { getListItemEnteringAnimation } from 'common/utils/animations' +import React, { + RefObject, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState +} from 'react' +import { + LayoutChangeEvent, + LayoutRectangle, + NativeScrollEvent, + NativeSyntheticEvent, + Platform, + ScrollView, + ScrollViewProps, + View +} from 'react-native' +import { + KeyboardAwareScrollView, + useKeyboardState +} from 'react-native-keyboard-controller' +import Animated, { + Extrapolation, + FadeIn, + interpolate, + useAnimatedStyle, + useSharedValue +} from 'react-native-reanimated' +import { + useSafeAreaFrame, + useSafeAreaInsets +} from 'react-native-safe-area-context' +import { ErrorState } from './ErrorState' +import Grabber from './Grabber' +import { LinearGradientBottomWrapper } from './LinearGradientBottomWrapper' + +// Use this component when you need to display a list of items in a screen. +// It handles all the logic for the header and footer, including keyboard interactions and gestures. + +// It provides: +// - A navigation bar with a title +// - A header with a title +// - Custom sticky header and footer components +// - Custom empty state component +// - Proper keyboard avoidance and handling + +// Used by all screens that display a list of items + +export interface ListScreenProps + extends Omit< + FlashListProps, + 'ListHeaderComponent' | 'ListFooterComponent' + > { + /** The title displayed in the screen header */ + title: string + /** Optional subtitle displayed below the title */ + subtitle?: string + /** Optional title to display in the navigation bar */ + navigationTitle?: string + /** Array of data items to be rendered in the list */ + data: T[] + /** Whether this screen has a parent screen in the navigation stack */ + hasParent?: boolean + /** Whether this screen is presented as a modal */ + isModal?: boolean + /** Whether this screen has a tab bar */ + hasTabBar?: boolean + /** Optional background color */ + backgroundColor?: string + /** Whether to show the navigation header title */ + showNavigationHeaderTitle?: boolean + /** Optional function to render a custom sticky header component */ + renderHeader?: () => React.ReactNode + /** Optional function to render content in the navigation bar's right side */ + renderHeaderRight?: () => React.ReactNode + /** Optional function to render content when the list is empty */ + renderEmpty?: () => React.ReactNode + /** Optional function to render a fixed footer at the bottom of the screen */ + renderFooter?: () => React.ReactNode + /** Whether to show the sticky header */ + shouldShowStickyHeader?: boolean + /** Optional ref to the flat list */ + flatListRef?: RefObject> +} + +export type ListScreenRef = { + scrollViewRef?: RefObject> +} + +export const ListScreenV2 = ({ + data, + title, + subtitle, + navigationTitle, + showNavigationHeaderTitle = true, + hasParent, + hasTabBar, + backgroundColor, + isModal, + renderEmpty, + renderHeader, + renderHeaderRight, + renderFooter, + shouldShowStickyHeader = true, + flatListRef, + ...props +}: ListScreenProps): JSX.Element => { + const insets = useSafeAreaInsets() + const headerHeight = useHeaderHeight() + const keyboard = useKeyboardState() + const frame = useSafeAreaFrame() + + const [targetLayout, setTargetLayout] = useState< + LayoutRectangle | undefined + >() + const scrollViewRef = useRef>(null) + + // Shared values for worklets (UI thread animations) + const titleHeight = useSharedValue(0) + const subtitleHeight = useSharedValue(0) + + // State for React re-renders (used in useMemo) + const [contentHeaderHeight, setContentHeaderHeight] = useState(0) + const [renderHeaderHeight, setRenderHeaderHeight] = useState(0) + const [footerHeight, setFooterHeight] = useState(0) + const [showFooter, setShowFooter] = useState(false) + + useEffect(() => { + if (renderFooter && !showFooter) { + setShowFooter(true) + } + }, [renderFooter, showFooter]) + + useImperativeHandle( + flatListRef, + () => ({ + scrollViewRef: scrollViewRef as RefObject> + }), + [scrollViewRef] + ) + + const { onScroll, scrollY, targetHiddenProgress } = useFadingHeaderNavigation( + { + header: , + targetLayout, + shouldHeaderHaveGrabber: isModal, + hideHeaderBackground: shouldShowStickyHeader, + hasSeparator: shouldShowStickyHeader + ? renderHeader + ? false + : true + : true, + hasBackgroundAnimation: !shouldShowStickyHeader, + backgroundColor, + hasParent, + showNavigationHeaderTitle, + renderHeaderRight + } + ) + + const onScrollEvent = useCallback( + (event: NativeSyntheticEvent) => { + onScroll(event) + }, + [onScroll] + ) + + const onScrollEndDrag = useCallback( + (event: NativeSyntheticEvent): void => { + if (event.nativeEvent.contentOffset.y < contentHeaderHeight) { + if (event.nativeEvent.contentOffset.y > titleHeight.value) { + scrollViewRef.current?.scrollToOffset({ + offset: + event.nativeEvent.contentOffset.y > contentHeaderHeight + ? event.nativeEvent.contentOffset.y + : contentHeaderHeight, + animated: true + }) + } else { + scrollViewRef.current?.scrollToOffset({ + offset: 0, + animated: true + }) + } + } + }, + [contentHeaderHeight, titleHeight] + ) + + const handleTitleLayout = useCallback( + (event: LayoutChangeEvent) => { + const { height, x, y, width } = event.nativeEvent.layout + titleHeight.value = height + setTargetLayout({ + x, + y, + width, + height + }) + }, + [titleHeight] + ) + + const handleSubtitleLayout = useCallback( + (event: LayoutChangeEvent) => { + const { height } = event.nativeEvent.layout + subtitleHeight.value = height + }, + [subtitleHeight] + ) + + const handleRenderHeaderLayout = useCallback((event: LayoutChangeEvent) => { + const { height } = event.nativeEvent.layout + setRenderHeaderHeight(height) + }, []) + + const handleContentHeaderLayout = useCallback((event: LayoutChangeEvent) => { + const { height } = event.nativeEvent.layout + setContentHeaderHeight(height) + }, []) + + const animatedHeaderContainerStyle = useAnimatedStyle(() => { + const translateY = interpolate( + scrollY.value, + [0, headerHeight], + [0, -headerHeight], + Extrapolation.CLAMP + ) + + return { + zIndex: 10, + transform: [ + { + translateY: shouldShowStickyHeader ? translateY : 0 + } + ] + } + }) + + const animatedTitleStyle = useAnimatedStyle(() => { + const scale = interpolate( + scrollY.value, + [ + -titleHeight.value - subtitleHeight.value, + 0, + titleHeight.value + subtitleHeight.value + ], + [Platform.OS === 'ios' ? 0.98 : 1, 1, 0.95] + ) + return { + opacity: 1 - targetHiddenProgress.value, + transform: [{ scale: data.length === 0 ? 1 : scale }] + } + }) + + const animatedSubtitleStyle = useAnimatedStyle(() => { + return { + opacity: 1 - targetHiddenProgress.value + } + }) + + const animatedHeaderBlurStyle = useAnimatedStyle(() => { + // if we have a background color, we need to animate the opacity of the blur view + // so that it blends with the background color after scrolling + return { + opacity: !shouldShowStickyHeader + ? 0 + : backgroundColor + ? targetHiddenProgress.value + : 1 + } + }) + + const animatedBorderStyle = useAnimatedStyle(() => { + const opacity = interpolate(scrollY.value, [0, headerHeight], [0, 1]) + return { + opacity: shouldShowStickyHeader ? opacity : 0 + } + }) + + const animatedListContainer = useAnimatedStyle(() => { + const top = interpolate( + scrollY.value, + [0, headerHeight], + [0, -headerHeight], + Extrapolation.CLAMP + ) + + const bottom = interpolate( + scrollY.value, + [0, headerHeight], + [-headerHeight, 0], + Extrapolation.CLAMP + ) + + return { + flex: 1, + position: 'absolute', + top, + bottom, + left: 0, + right: 0 + } + }) + + const minHeight = useMemo(() => { + const extraPadding = Platform.OS === 'android' ? (isModal ? 24 : 8) : -40 + + return ( + frame.height - + headerHeight - + contentHeaderHeight - + renderHeaderHeight - + extraPadding - + (keyboard.isVisible ? keyboard.height : 0) + ) + }, [ + contentHeaderHeight, + frame.height, + headerHeight, + isModal, + keyboard.height, + keyboard.isVisible, + renderHeaderHeight + ]) + + const contentContainerStyle = useMemo(() => { + // FlashList's contentContainerStyle only supports: + // backgroundColor, paddingTop, paddingBottom, paddingLeft, paddingRight, padding + // Do NOT pass minHeight, flex, or other unsupported properties + return { + ...(props?.contentContainerStyle ?? {}), + paddingBottom: renderFooter ? footerHeight + 16 : 16 + } + }, [renderFooter, footerHeight, props?.contentContainerStyle]) + + const animatedEmptyComponent = useAnimatedStyle(() => { + const translateY = interpolate( + scrollY.value, + [0, headerHeight], + [-headerHeight, 0], + Extrapolation.CLAMP + ) + + return { + minHeight, + alignItems: 'center', + justifyContent: 'center', + transform: [ + { + translateY + } + ] + } + }) + + const renderEmptyComponent = useCallback(() => { + if (renderEmpty) { + return renderEmpty() + } + return ( + + ) + }, [renderEmpty]) + + const ListEmptyComponent = useMemo(() => { + return ( + + {renderEmptyComponent()} + + ) + }, [animatedEmptyComponent, renderEmptyComponent]) + + const renderGrabber = useCallback(() => { + if (isModal) + return ( + + + + ) + }, [isModal]) + + const handleFooterLayout = useCallback((event: LayoutChangeEvent) => { + const { height } = event.nativeEvent.layout + setFooterHeight(height) + }, []) + + const renderHeaderComponent = useCallback(() => { + return ( + + + + + + + {title ? ( + + {title} + + ) : null} + + {subtitle ? ( + + + {subtitle} + + + ) : null} + + {renderHeader && ( + + {renderHeader?.()} + + )} + + + + + + ) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + renderHeader, + headerHeight, + backgroundColor, + handleContentHeaderLayout, + title, + handleTitleLayout, + subtitle, + handleSubtitleLayout, + handleRenderHeaderLayout + ]) + + const renderFooterContent = useCallback(() => { + if (renderFooter && showFooter) { + const footer = renderFooter() + if (footer) { + return ( + + + {footer} + + + ) + } + } + return null + }, [renderFooter, showFooter, handleFooterLayout, insets.bottom]) + + const overrideProps = useMemo(() => { + return { + contentContainerStyle: { + ...contentContainerStyle, + minHeight + } + } + }, [contentContainerStyle, minHeight]) + + return ( + + {renderHeaderComponent()} + + + + + + + + {renderGrabber()} + {renderFooterContent()} + + ) +} + +const RenderScrollComponent = React.forwardRef( + (props, ref) => +) diff --git a/packages/core-mobile/app/new/features/swapV2/screens/SelectSwapV2TokenScreen.tsx b/packages/core-mobile/app/new/features/swapV2/screens/SelectSwapV2TokenScreen.tsx index a01eb9c03d..8e61c8ecbb 100644 --- a/packages/core-mobile/app/new/features/swapV2/screens/SelectSwapV2TokenScreen.tsx +++ b/packages/core-mobile/app/new/features/swapV2/screens/SelectSwapV2TokenScreen.tsx @@ -1,30 +1,30 @@ -import React, { useMemo, useState, useCallback, useEffect } from 'react' +import { ChainId, Network } from '@avalabs/core-chains-sdk' import { + ActivityIndicator, + Button, Icons, SCREEN_WIDTH, + SearchBar, Separator, Text, TouchableOpacity, useTheme, - View, - Button, - SearchBar, - ActivityIndicator + View } from '@avalabs/k2-alpine' -import { ListRenderItem } from 'react-native' +import { ErrorState } from 'common/components/ErrorState' +import { ListScreenV2 } from 'common/components/ListScreenV2' import { useRouter } from 'expo-router' -import { LocalTokenWithBalance } from 'store/balance' import { LogoWithNetwork } from 'features/portfolio/assets/components/LogoWithNetwork' -import { ListScreen } from 'common/components/ListScreen' import useCChainNetwork from 'hooks/earn/useCChainNetwork' import useSolanaNetwork from 'hooks/earn/useSolanaNetwork' +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { ListRenderItem } from '@shopify/flash-list' import { useSelector } from 'react-redux' +import { LocalTokenWithBalance } from 'store/balance' import { selectIsSolanaSwapBlocked } from 'store/posthog' -import { ChainId, Network } from '@avalabs/core-chains-sdk' import { getCaip2ChainId } from 'utils/caip2ChainIds' -import { ErrorState } from 'common/components/ErrorState' -import { useSwapV2Tokens } from '../hooks/useSwapV2Tokens' import { useFilteredSwapTokens } from '../hooks/useFilteredSwapTokens' +import { useSwapV2Tokens } from '../hooks/useSwapV2Tokens' export const SelectSwapV2TokenScreen = ({ selectedToken, @@ -195,34 +195,21 @@ export const SelectSwapV2TokenScreen = ({ const renderEmpty = useCallback(() => { if (isLoading) { - return ( - - - - ) + return } - - return ( - - ) + return }, [isLoading]) return ( - item.localId} + estimatedItemSize={66} + keyExtractor={(item: LocalTokenWithBalance) => + `token-${item.localId}-${item.internalId}` + } renderHeader={renderHeader} renderEmpty={renderEmpty} />