From 95e7d44035a1ff5bb6638ff3206b79d47bcadbd1 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sun, 28 Dec 2025 13:29:33 +0530 Subject: [PATCH] addes scrolltotop by clicking tab navigation buttons --- src/contexts/ScrollToTopContext.tsx | 57 +++++++++++++++++++++++++++++ src/navigation/AppNavigator.tsx | 55 +++++++++++++++++++++++++--- src/screens/DownloadsScreen.tsx | 12 +++++- src/screens/HomeScreen.tsx | 21 +++++++++++ src/screens/LibraryScreen.tsx | 14 ++++++- src/screens/SearchScreen.tsx | 10 +++++ src/screens/SettingsScreen.tsx | 47 ++++++++++++++++++++++-- 7 files changed, 206 insertions(+), 10 deletions(-) create mode 100644 src/contexts/ScrollToTopContext.tsx diff --git a/src/contexts/ScrollToTopContext.tsx b/src/contexts/ScrollToTopContext.tsx new file mode 100644 index 00000000..49591823 --- /dev/null +++ b/src/contexts/ScrollToTopContext.tsx @@ -0,0 +1,57 @@ +import React, { createContext, useContext, useCallback, useRef, useEffect } from 'react'; + +type ScrollToTopListener = () => void; + +interface ScrollToTopContextType { + emitScrollToTop: (routeName: string) => void; + subscribe: (routeName: string, listener: ScrollToTopListener) => () => void; +} + +const ScrollToTopContext = createContext(null); + +export const ScrollToTopProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const listenersRef = useRef>>(new Map()); + + const subscribe = useCallback((routeName: string, listener: ScrollToTopListener) => { + if (!listenersRef.current.has(routeName)) { + listenersRef.current.set(routeName, new Set()); + } + listenersRef.current.get(routeName)!.add(listener); + + // Return unsubscribe function + return () => { + listenersRef.current.get(routeName)?.delete(listener); + }; + }, []); + + const emitScrollToTop = useCallback((routeName: string) => { + const listeners = listenersRef.current.get(routeName); + if (listeners) { + listeners.forEach(listener => listener()); + } + }, []); + + return ( + + {children} + + ); +}; + +export const useScrollToTop = (routeName: string, scrollToTop: () => void) => { + const context = useContext(ScrollToTopContext); + + useEffect(() => { + if (!context) return; + + const unsubscribe = context.subscribe(routeName, scrollToTop); + return unsubscribe; + }, [context, routeName, scrollToTop]); +}; + +export const useScrollToTopEmitter = () => { + const context = useContext(ScrollToTopContext); + return context?.emitScrollToTop || (() => { }); +}; + +export default ScrollToTopContext; diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index f3e43e2a..ed0c4ce3 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -16,6 +16,7 @@ import { Stream } from '../types/streams'; import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTheme } from '../contexts/ThemeContext'; import { PostHogProvider } from 'posthog-react-native'; +import { ScrollToTopProvider, useScrollToTopEmitter } from '../contexts/ScrollToTopContext'; // Optional iOS Glass effect (expo-glass-effect) with safe fallback let GlassViewComp: any = null; @@ -581,6 +582,7 @@ const MainTabs = () => { const isIosTablet = Platform.OS === 'ios' && isTablet; const [hidden, setHidden] = React.useState(HeaderVisibility.isHidden()); React.useEffect(() => HeaderVisibility.subscribe(setHidden), []); + const emitScrollToTop = useScrollToTopEmitter(); // Smooth animate header hide/show const headerAnim = React.useRef(new Animated.Value(0)).current; // 0: shown, 1: hidden React.useEffect(() => { @@ -674,7 +676,10 @@ const MainTabs = () => { target: route.key, canPreventDefault: true, }); - if (!isFocused && !event.defaultPrevented) { + if (isFocused) { + // Same tab pressed - emit scroll to top + emitScrollToTop(route.name); + } else if (!event.defaultPrevented) { props.navigation.navigate(route.name); } }; @@ -789,7 +794,10 @@ const MainTabs = () => { canPreventDefault: true, }); - if (!isFocused && !event.defaultPrevented) { + if (isFocused) { + // Same tab pressed - emit scroll to top + emitScrollToTop(route.name); + } else if (!event.defaultPrevented) { props.navigation.navigate(route.name); } }; @@ -893,6 +901,13 @@ const MainTabs = () => { tabBarIcon: () => ({ sfSymbol: 'house' }), freezeOnBlur: true, }} + listeners={({ navigation }) => ({ + tabPress: (e) => { + if (navigation.isFocused()) { + emitScrollToTop('Home'); + } + }, + })} /> { title: 'Library', tabBarIcon: () => ({ sfSymbol: 'heart' }), }} + listeners={({ navigation }) => ({ + tabPress: (e) => { + if (navigation.isFocused()) { + emitScrollToTop('Library'); + } + }, + })} /> { title: 'Search', tabBarIcon: () => ({ sfSymbol: 'magnifyingglass' }), }} + listeners={({ navigation }) => ({ + tabPress: (e) => { + if (navigation.isFocused()) { + emitScrollToTop('Search'); + } + }, + })} /> {downloadsEnabled && ( { title: 'Downloads', tabBarIcon: () => ({ sfSymbol: 'arrow.down.circle' }), }} + listeners={({ navigation }) => ({ + tabPress: (e) => { + if (navigation.isFocused()) { + emitScrollToTop('Downloads'); + } + }, + })} /> )} { title: 'Settings', tabBarIcon: () => ({ sfSymbol: 'gear' }), }} + listeners={({ navigation }) => ({ + tabPress: (e) => { + if (navigation.isFocused()) { + emitScrollToTop('Settings'); + } + }, + })} /> @@ -1612,9 +1655,11 @@ const AppNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootStack host: "https://us.i.posthog.com", }} > - - - + + + + + ); diff --git a/src/screens/DownloadsScreen.tsx b/src/screens/DownloadsScreen.tsx index 2b206379..1042b946 100644 --- a/src/screens/DownloadsScreen.tsx +++ b/src/screens/DownloadsScreen.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState, useEffect, useMemo } from 'react'; +import React, { useCallback, useState, useEffect, useMemo, useRef } from 'react'; import { View, Text, @@ -35,6 +35,7 @@ import type { DownloadItem } from '../contexts/DownloadsContext'; import { useToast } from '../contexts/ToastContext'; import CustomAlert from '../components/CustomAlert'; import ScreenHeader from '../components/common/ScreenHeader'; +import { useScrollToTop } from '../contexts/ScrollToTopContext'; const { height, width } = Dimensions.get('window'); const isTablet = width >= 768; @@ -355,6 +356,14 @@ const DownloadsScreen: React.FC = () => { const [showHelpAlert, setShowHelpAlert] = useState(false); const [showRemoveAlert, setShowRemoveAlert] = useState(false); const [pendingRemoveItem, setPendingRemoveItem] = useState(null); + const flatListRef = useRef(null); + + // Scroll to top handler + const scrollToTop = useCallback(() => { + flatListRef.current?.scrollToOffset({ offset: 0, animated: true }); + }, []); + + useScrollToTop('Downloads', scrollToTop); // Filter downloads based on selected filter const filteredDownloads = useMemo(() => { @@ -656,6 +665,7 @@ const DownloadsScreen: React.FC = () => { ) : ( item.id} renderItem={({ item }) => ( diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index e748dde6..026ae697 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -65,6 +65,7 @@ import { useToast } from '../contexts/ToastContext'; import FirstTimeWelcome from '../components/FirstTimeWelcome'; import { HeaderVisibility } from '../contexts/HeaderVisibility'; import { useTrailer } from '../contexts/TrailerContext'; +import { useScrollToTop } from '../contexts/ScrollToTopContext'; // Constants const CATALOG_SETTINGS_KEY = 'catalog_settings'; @@ -137,6 +138,25 @@ const HomeScreen = () => { const totalCatalogsRef = useRef(0); const [visibleCatalogCount, setVisibleCatalogCount] = useState(5); // Reduced for memory const insets = useSafeAreaInsets(); + const flashListRef = useRef(null); + + // Scroll to top handler - use scrollToIndex and retry to handle re-renders + const scrollToTop = useCallback(() => { + // First attempt + flashListRef.current?.scrollToOffset({ offset: 0, animated: true }); + + // Retry after a short delay in case re-render interrupted the scroll + setTimeout(() => { + flashListRef.current?.scrollToOffset({ offset: 0, animated: true }); + }, 150); + + // Final retry to ensure we're at the top + setTimeout(() => { + flashListRef.current?.scrollToOffset({ offset: 0, animated: false }); + }, 400); + }, []); + + useScrollToTop('Home', scrollToTop); // Stabilize insets to prevent iOS layout shifts const [stableInsetsTop, setStableInsetsTop] = useState(insets.top); @@ -890,6 +910,7 @@ const HomeScreen = () => { translucent /> { const insets = useSafeAreaInsets(); const { currentTheme } = useTheme(); const { settings } = useSettings(); + const flashListRef = useRef(null); + + // Scroll to top handler + const scrollToTop = useCallback(() => { + flashListRef.current?.scrollToOffset({ offset: 0, animated: true }); + }, []); + + useScrollToTop('Library', scrollToTop); const { isAuthenticated: traktAuthenticated, @@ -733,6 +742,7 @@ const LibraryScreen = () => { return ( renderTraktCollectionFolder({ folder: item })} keyExtractor={item => item.id} @@ -774,6 +784,7 @@ const LibraryScreen = () => { return ( renderTraktItem({ item })} keyExtractor={(item) => `${item.type}-${item.id}`} @@ -874,6 +885,7 @@ const LibraryScreen = () => { return ( renderItem({ item: item as LibraryItem })} keyExtractor={item => item.id} diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index 82a27115..d4db0460 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -44,6 +44,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTheme } from '../contexts/ThemeContext'; import LoadingSpinner from '../components/common/LoadingSpinner'; import ScreenHeader from '../components/common/ScreenHeader'; +import { useScrollToTop } from '../contexts/ScrollToTopContext'; const { width, height } = Dimensions.get('window'); @@ -237,6 +238,14 @@ const SearchScreen = () => { const isInitialMount = useRef(true); // Track mount status for async operations const isMounted = useRef(true); + const scrollViewRef = useRef(null); + + // Scroll to top handler + const scrollToTop = useCallback(() => { + scrollViewRef.current?.scrollTo({ y: 0, animated: true }); + }, []); + + useScrollToTop('Search', scrollToTop); useEffect(() => { isMounted.current = true; @@ -994,6 +1003,7 @@ const SearchScreen = () => { ) : ( = 768; @@ -320,6 +321,17 @@ const SettingsScreen: React.FC = () => { const [displayDownloads, setDisplayDownloads] = useState(null); const [isCountingUp, setIsCountingUp] = useState(false); + // Scroll to top ref and handler + const mobileScrollViewRef = useRef(null); + const tabletScrollViewRef = useRef(null); + + const scrollToTop = useCallback(() => { + mobileScrollViewRef.current?.scrollTo({ y: 0, animated: true }); + tabletScrollViewRef.current?.scrollTo({ y: 0, animated: true }); + }, []); + + useScrollToTop('Settings', scrollToTop); + // Add a useEffect to check Trakt authentication status on focus useEffect(() => { // This will reload the Trakt auth status whenever the settings screen is focused @@ -923,6 +935,7 @@ const SettingsScreen: React.FC = () => { } ]}> { /> + + + + Made with ❤️ by Tapframe and Friends @@ -1040,6 +1061,7 @@ const SettingsScreen: React.FC = () => { { /> + + + + Made with ❤️ by Tapframe and friends @@ -1368,7 +1398,7 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', marginTop: 0, - marginBottom: 12, + marginBottom: 48, }, footerText: { fontSize: 13, @@ -1437,12 +1467,23 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', marginTop: 0, - marginBottom: 32, + marginBottom: 16, }, monkeyAnimation: { width: 180, height: 180, }, + brandLogoContainer: { + alignItems: 'center', + justifyContent: 'center', + marginTop: 0, + marginBottom: 16, + opacity: 0.8, + }, + brandLogo: { + width: 120, + height: 40, + }, }); export default SettingsScreen; \ No newline at end of file