addes scrolltotop by clicking tab navigation buttons

This commit is contained in:
tapframe 2025-12-28 13:29:33 +05:30
parent d39a485d24
commit 95e7d44035
7 changed files with 206 additions and 10 deletions

View file

@ -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<ScrollToTopContextType | null>(null);
export const ScrollToTopProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const listenersRef = useRef<Map<string, Set<ScrollToTopListener>>>(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 (
<ScrollToTopContext.Provider value={{ emitScrollToTop, subscribe }}>
{children}
</ScrollToTopContext.Provider>
);
};
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;

View file

@ -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');
}
},
})}
/>
<IOSTab.Screen
name="Library"
@ -901,6 +916,13 @@ const MainTabs = () => {
title: 'Library',
tabBarIcon: () => ({ sfSymbol: 'heart' }),
}}
listeners={({ navigation }) => ({
tabPress: (e) => {
if (navigation.isFocused()) {
emitScrollToTop('Library');
}
},
})}
/>
<IOSTab.Screen
name="Search"
@ -909,6 +931,13 @@ const MainTabs = () => {
title: 'Search',
tabBarIcon: () => ({ sfSymbol: 'magnifyingglass' }),
}}
listeners={({ navigation }) => ({
tabPress: (e) => {
if (navigation.isFocused()) {
emitScrollToTop('Search');
}
},
})}
/>
{downloadsEnabled && (
<IOSTab.Screen
@ -918,6 +947,13 @@ const MainTabs = () => {
title: 'Downloads',
tabBarIcon: () => ({ sfSymbol: 'arrow.down.circle' }),
}}
listeners={({ navigation }) => ({
tabPress: (e) => {
if (navigation.isFocused()) {
emitScrollToTop('Downloads');
}
},
})}
/>
)}
<IOSTab.Screen
@ -927,6 +963,13 @@ const MainTabs = () => {
title: 'Settings',
tabBarIcon: () => ({ sfSymbol: 'gear' }),
}}
listeners={({ navigation }) => ({
tabPress: (e) => {
if (navigation.isFocused()) {
emitScrollToTop('Settings');
}
},
})}
/>
</IOSTab.Navigator>
</View>
@ -1612,9 +1655,11 @@ const AppNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootStack
host: "https://us.i.posthog.com",
}}
>
<LoadingProvider>
<InnerNavigator initialRouteName={initialRouteName} />
</LoadingProvider>
<ScrollToTopProvider>
<LoadingProvider>
<InnerNavigator initialRouteName={initialRouteName} />
</LoadingProvider>
</ScrollToTopProvider>
</PostHogProvider>
);

View file

@ -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<DownloadItem | null>(null);
const flatListRef = useRef<FlatList>(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 = () => {
<EmptyDownloadsState navigation={navigation} />
) : (
<FlatList
ref={flatListRef}
data={filteredDownloads}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (

View file

@ -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<any>(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
/>
<FlashList
ref={flashListRef}
data={listData}
renderItem={renderListItem}
keyExtractor={keyExtractor}

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { DeviceEventEmitter } from 'react-native';
import { Share } from 'react-native';
import { mmkvStorage } from '../services/mmkvStorage';
@ -38,6 +38,7 @@ import TraktIcon from '../../assets/rating-icons/trakt.svg';
import { traktService, TraktService, TraktImages } from '../services/traktService';
import { TraktLoadingSpinner } from '../components/common/TraktLoadingSpinner';
import { useSettings } from '../hooks/useSettings';
import { useScrollToTop } from '../contexts/ScrollToTopContext';
interface LibraryItem extends StreamingContent {
progress?: number;
@ -225,6 +226,14 @@ const LibraryScreen = () => {
const insets = useSafeAreaInsets();
const { currentTheme } = useTheme();
const { settings } = useSettings();
const flashListRef = useRef<any>(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 (
<FlashList
ref={flashListRef}
data={traktFolders}
renderItem={({ item }) => renderTraktCollectionFolder({ folder: item })}
keyExtractor={item => item.id}
@ -774,6 +784,7 @@ const LibraryScreen = () => {
return (
<FlashList
ref={flashListRef}
data={folderItems}
renderItem={({ item }) => renderTraktItem({ item })}
keyExtractor={(item) => `${item.type}-${item.id}`}
@ -874,6 +885,7 @@ const LibraryScreen = () => {
return (
<FlashList
ref={flashListRef}
data={filteredItems}
renderItem={({ item }) => renderItem({ item: item as LibraryItem })}
keyExtractor={item => item.id}

View file

@ -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<ScrollView>(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 = () => {
</View>
) : (
<ScrollView
ref={scrollViewRef}
style={styles.scrollView}
contentContainerStyle={styles.scrollViewContent}
keyboardShouldPersistTaps="handled"

View file

@ -1,4 +1,4 @@
import React, { useCallback, useState, useEffect } from 'react';
import React, { useCallback, useState, useEffect, useRef } from 'react';
import {
View,
Text,
@ -40,6 +40,7 @@ import TraktIcon from '../components/icons/TraktIcon';
import TMDBIcon from '../components/icons/TMDBIcon';
import MDBListIcon from '../components/icons/MDBListIcon';
import { campaignService } from '../services/campaignService';
import { useScrollToTop } from '../contexts/ScrollToTopContext';
const { width, height } = Dimensions.get('window');
const isTablet = width >= 768;
@ -320,6 +321,17 @@ const SettingsScreen: React.FC = () => {
const [displayDownloads, setDisplayDownloads] = useState<number | null>(null);
const [isCountingUp, setIsCountingUp] = useState<boolean>(false);
// Scroll to top ref and handler
const mobileScrollViewRef = useRef<ScrollView>(null);
const tabletScrollViewRef = useRef<ScrollView>(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 = () => {
}
]}>
<ScrollView
ref={tabletScrollViewRef}
style={styles.tabletScrollView}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.tabletScrollContent}
@ -1005,6 +1018,14 @@ const SettingsScreen: React.FC = () => {
/>
</View>
<View style={styles.brandLogoContainer}>
<FastImage
source={require('../../assets/nuviotext.png')}
style={styles.brandLogo}
resizeMode={FastImage.resizeMode.contain}
/>
</View>
<View style={styles.footer}>
<Text style={[styles.footerText, { color: currentTheme.colors.mediumEmphasis }]}>
Made with by Tapframe and Friends
@ -1040,6 +1061,7 @@ const SettingsScreen: React.FC = () => {
<View style={styles.contentContainer}>
<ScrollView
ref={mobileScrollViewRef}
style={styles.scrollView}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
@ -1131,6 +1153,14 @@ const SettingsScreen: React.FC = () => {
/>
</View>
<View style={styles.brandLogoContainer}>
<FastImage
source={require('../../assets/nuviotext.png')}
style={styles.brandLogo}
resizeMode={FastImage.resizeMode.contain}
/>
</View>
<View style={styles.footer}>
<Text style={[styles.footerText, { color: currentTheme.colors.mediumEmphasis }]}>
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;