import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { useColor } from '@/hooks/useColor'; import { BORDER_RADIUS } from '@/theme/globals'; import { Image } from 'expo-image'; import { Download, Share, X } from 'lucide-react-native'; import { memo, useCallback, useEffect, useRef, useState } from 'react'; import { Dimensions, FlatList, Modal, Pressable, ScrollView, StyleSheet, View, } from 'react-native'; import { Gesture, GestureDetector, GestureHandlerRootView, } from 'react-native-gesture-handler'; import Animated, { runOnJS, useAnimatedStyle, useSharedValue, withSpring, } from 'react-native-reanimated'; const { width: screenWidth, height: screenHeight } = Dimensions.get('window'); export interface GalleryItem { id: string; uri: string; title?: string; description?: string; thumbnail?: string; } interface GalleryProps { items: GalleryItem[]; columns?: number; spacing?: number; borderRadius?: number; aspectRatio?: number; showPages?: boolean; showTitles?: boolean; showDescriptions?: boolean; enableFullscreen?: boolean; enableZoom?: boolean; enableDownload?: boolean; enableShare?: boolean; onItemPress?: (item: GalleryItem, index: number) => void; onDownload?: (item: GalleryItem) => void; onShare?: (item: GalleryItem) => void; renderCustomOverlay?: (item: GalleryItem, index: number) => React.ReactNode; } const AnimatedImage = Animated.createAnimatedComponent(Image); // Improved zoom hook with better gesture handling interface UseImageZoomProps { enableZoom: boolean; onSetCanSwipe: (canSwipe: boolean) => void; shouldReset?: boolean; // Indicates if the current image has changed and zoom should reset } export const useImageZoom = ({ enableZoom, onSetCanSwipe, shouldReset = false, }: UseImageZoomProps) => { // Shared values for animated properties const scale = useSharedValue(1); const translateX = useSharedValue(0); const translateY = useSharedValue(0); // Saved values to store the state at the start of a gesture const savedScale = useSharedValue(1); const savedTranslateX = useSharedValue(0); const savedTranslateY = useSharedValue(0); // Shared value to dynamically enable/disable the pan gesture for dragging const panGestureEnabled = useSharedValue(false); // Initially disabled // Minimum and maximum zoom scale const minScale = 0.8; const maxScale = 4; // Function to reset the image to its initial state (no zoom, no translation) const resetZoom = useCallback(() => { 'worklet'; // Marks this function to run on the UI thread scale.value = withSpring(1, { damping: 20, stiffness: 300 }); translateX.value = withSpring(0, { damping: 20, stiffness: 300 }); translateY.value = withSpring(0, { damping: 20, stiffness: 300 }); savedScale.value = 1; savedTranslateX.value = 0; savedTranslateY.value = 0; // Allow the parent FlatList to swipe when the image is reset runOnJS(onSetCanSwipe)(true); panGestureEnabled.value = false; // Disable pan gesture when reset }, [ scale, translateX, translateY, savedScale, savedTranslateX, savedTranslateY, onSetCanSwipe, panGestureEnabled, ]); // Effect to reset zoom when the `shouldReset` prop changes (meaning a new image is selected) useEffect(() => { if (shouldReset) { resetZoom(); } }, [shouldReset, resetZoom]); // Function to constrain the image translation within its bounds const constrainTranslation = useCallback( (newScale: number, newTranslateX: number, newTranslateY: number) => { 'worklet'; // Calculate maximum allowed translation based on current scale const maxTranslateX = Math.max( 0, (screenWidth * newScale - screenWidth) / 2 ); const maxTranslateY = Math.max( 0, (screenHeight * newScale - screenHeight) / 2 ); // Constrain the new translation values const constrainedX = Math.max( -maxTranslateX, Math.min(maxTranslateX, newTranslateX) ); const constrainedY = Math.max( -maxTranslateY, Math.min(maxTranslateY, newTranslateY) ); return { x: constrainedX, y: constrainedY }; }, [] ); // Gesture for double-tapping to zoom in/out const doubleTapGesture = Gesture.Tap() .numberOfTaps(2) .onEnd((event) => { if (!enableZoom) return; // Only process if zoom is enabled ('worklet'); // If already zoomed in (beyond a small threshold), reset to original size if (scale.value > 1.1) { resetZoom(); // This will handle setting panGestureEnabled and canSwipe } else { // Otherwise, zoom to a target scale (e.g., 2.5x) const targetScale = 2.5; // Calculate tap position relative to the center of the screen const tapX = event.x - screenWidth / 2; const tapY = event.y - screenHeight / 2; // Calculate new translation to make the tapped point the new center const newTranslateX = (-tapX * (targetScale - 1)) / targetScale; const newTranslateY = (-tapY * (targetScale - 1)) / targetScale; // Constrain translation to keep image within bounds const constrained = constrainTranslation( targetScale, newTranslateX, newTranslateY ); // Animate scale and translation scale.value = withSpring(targetScale, { damping: 20, stiffness: 300 }); translateX.value = withSpring(constrained.x, { damping: 20, stiffness: 300, }); translateY.value = withSpring(constrained.y, { damping: 20, stiffness: 300, }); // Save current state savedScale.value = targetScale; savedTranslateX.value = constrained.x; savedTranslateY.value = constrained.y; // Disable parent FlatList swiping as image is now zoomed runOnJS(onSetCanSwipe)(false); panGestureEnabled.value = true; // Enable pan gesture for dragging zoomed image } }); // Gesture for pinching to zoom const pinchGesture = Gesture.Pinch() .onStart(() => { if (!enableZoom) return; ('worklet'); // Save current state at the start of the pinch savedScale.value = scale.value; savedTranslateX.value = translateX.value; savedTranslateY.value = translateY.value; }) .onUpdate((event) => { if (!enableZoom) return; ('worklet'); // Calculate new scale, clamping it between min and max const newScale = Math.max( minScale, Math.min(maxScale, savedScale.value * event.scale) ); // Calculate focal point relative to the image center const focalX = event.focalX - screenWidth / 2; const focalY = event.focalY - screenHeight / 2; // Calculate new translation to keep the focal point in place during zoom const scaleDiff = newScale / savedScale.value; const newTranslateX = savedTranslateX.value + focalX * (1 - scaleDiff); const newTranslateY = savedTranslateY.value + focalY * (1 - scaleDiff); // Constrain translation const constrained = constrainTranslation( newScale, newTranslateX, newTranslateY ); // Apply new scale and translation scale.value = newScale; translateX.value = constrained.x; translateY.value = constrained.y; // Dynamically enable/disable panGestureEnabled and FlatList scrolling based on zoom level panGestureEnabled.value = newScale > 1.1; runOnJS(onSetCanSwipe)(newScale <= 1.1); // FlatList scrollable if not zoomed }) .onEnd(() => { if (!enableZoom) return; ('worklet'); // If zoomed out too much, reset to original size if (scale.value < 1) { resetZoom(); // This will handle setting panGestureEnabled and canSwipe } else { // Save current state after pinch ends savedScale.value = scale.value; savedTranslateX.value = translateX.value; savedTranslateY.value = translateY.value; // Re-evaluate if panGestureEnabled and FlatList should be able to swipe panGestureEnabled.value = scale.value > 1.1; runOnJS(onSetCanSwipe)(scale.value <= 1.1); } }); // Gesture for panning (dragging) the image when zoomed in const panGesture = Gesture.Pan() .minPointers(1) // This gesture will respond to a single finger .maxPointers(1) .enabled(panGestureEnabled.value) // Only enabled if panGestureEnabled.value is true .onStart(() => { 'worklet'; // If this onStart is called, it means the gesture is enabled and recognized. savedTranslateX.value = translateX.value; savedTranslateY.value = translateY.value; runOnJS(onSetCanSwipe)(false); // Disable parent FlatList swipe when dragging zoomed image }) .onUpdate((event) => { 'worklet'; // This check is a safeguard, but 'enabled' should prevent this from being called if not zoomed. if (!enableZoom || !panGestureEnabled.value) return; // Calculate new translation based on drag const newTranslateX = savedTranslateX.value + event.translationX; const newTranslateY = savedTranslateY.value + event.translationY; // Constrain translation const constrained = constrainTranslation( scale.value, newTranslateX, newTranslateY ); // Apply new translation translateX.value = constrained.x; translateY.value = constrained.y; }) .onEnd(() => { 'worklet'; savedTranslateX.value = translateX.value; savedTranslateY.value = translateY.value; // Re-enable FlatList swipe if not zoomed after pan ends runOnJS(onSetCanSwipe)(scale.value <= 1.1); }); // Compose all gestures: // - Race: Double tap takes precedence if detected. // - Simultaneous: Pinch and dynamically enabled single-finger pan can happen at the same time. const composedGesture = Gesture.Race( doubleTapGesture, Gesture.Simultaneous(pinchGesture, panGesture) ); // Animated style for the image based on scale and translation values const animatedImageStyle = useAnimatedStyle(() => { return { transform: [ { scale: scale.value }, { translateX: translateX.value }, { translateY: translateY.value }, ], }; }); return { animatedImageStyle, composedGesture, resetZoom, }; }; // Fixed fullscreen image component interface FullscreenImageProps { item: GalleryItem; index: number; selectedIndex: number; enableZoom: boolean; // Callback to inform the parent FlatList whether it should be scrollable onSetCanSwipe: (canSwipe: boolean) => void; } const FullscreenImage = memo( ({ item, index, selectedIndex, enableZoom, onSetCanSwipe, }: FullscreenImageProps) => { // Determine if this image is the currently selected one to trigger zoom reset const shouldReset = index === selectedIndex; const backgroundColor = useColor('background'); const { animatedImageStyle, composedGesture } = useImageZoom({ enableZoom, onSetCanSwipe, // Pass the callback to the hook shouldReset, }); return ( {/* GestureDetector always present if zoom is enabled */} {enableZoom ? ( ) : ( // If zoom is not enabled, render without GestureDetector )} ); } ); export function Gallery({ items, columns = 4, spacing = 0, aspectRatio = 1, borderRadius = 0, showPages = false, showTitles = false, showDescriptions = false, enableFullscreen = true, enableZoom = true, enableDownload = false, enableShare = false, onItemPress, onDownload, onShare, renderCustomOverlay, }: GalleryProps) { // State for the currently selected image index in fullscreen mode const [selectedIndex, setSelectedIndex] = useState(-1); // State to control modal visibility const [isModalVisible, setIsModalVisible] = useState(false); // State for the calculated width of the gallery container const [containerWidth, setContainerWidth] = useState(screenWidth); // State to control whether the fullscreen FlatList can be swiped horizontally const [flatListScrollEnabled, setFlatListScrollEnabled] = useState(true); // Refs for the FlatList components const fullscreenFlatListRef = useRef(null); const thumbnailFlatListRef = useRef(null); // Theme colors using custom hook const textColor = useColor('text'); const primary = useColor('primary'); const mutedColor = useColor('textMuted'); const backgroundColor = useColor('background'); const secondary = useColor('secondary'); // Calculate item width for the grid based on container width, columns, and spacing const itemWidth = (containerWidth - spacing * (columns - 1)) / columns; // Function to open the fullscreen modal const openFullscreen = useCallback( (index: number) => { if (!enableFullscreen) return; // Only open if fullscreen is enabled setSelectedIndex(index); setIsModalVisible(true); // Initially, allow FlatList scrolling setFlatListScrollEnabled(true); // Use setTimeout to ensure the modal is fully rendered before trying to scroll the FlatList setTimeout(() => { fullscreenFlatListRef.current?.scrollToIndex({ index, animated: false, }); thumbnailFlatListRef.current?.scrollToIndex({ index, animated: false, viewPosition: 0.5, // Center the thumbnail }); }, 100); }, [enableFullscreen] ); // Function to close the fullscreen modal const closeFullscreen = useCallback(() => { setIsModalVisible(false); setSelectedIndex(-1); // Reset selected index setFlatListScrollEnabled(true); // Ensure scrolling is re-enabled on close }, []); // Handler for pressing a gallery item (thumbnail) const handleItemPress = useCallback( (item: GalleryItem, index: number) => { if (onItemPress) { onItemPress(item, index); // Call custom press handler if provided } else if (enableFullscreen) { openFullscreen(index); // Otherwise, open fullscreen } }, [onItemPress, enableFullscreen, openFullscreen] ); // Handler for pressing a thumbnail in the fullscreen bottom bar const handleThumbnailPress = useCallback((index: number) => { setSelectedIndex(index); // Update selected index setFlatListScrollEnabled(true); // Always allow swiping when a thumbnail is tapped fullscreenFlatListRef.current?.scrollToIndex({ index, animated: true, }); }, []); // Callback for FlatList to detect when viewable items change (for updating selected index) const onViewableItemsChanged = useCallback( ({ viewableItems }: any) => { if (viewableItems.length > 0) { const newIndex = viewableItems[0].index; if ( newIndex !== selectedIndex && newIndex !== null && newIndex !== undefined ) { setSelectedIndex(newIndex); // Sync thumbnail scroll to the newly selected image setTimeout(() => { thumbnailFlatListRef.current?.scrollToIndex({ index: newIndex, animated: true, viewPosition: 0.5, // Center the thumbnail }); }, 100); } } }, [selectedIndex] ); // Configuration for viewability of FlatList items const viewabilityConfig = { itemVisiblePercentThreshold: 50, // An item is "viewable" if 50% of it is visible }; // Helper to get the currently displayed item in fullscreen const getCurrentItem = useCallback(() => { return selectedIndex >= 0 && selectedIndex < items.length ? items[selectedIndex] : null; }, [selectedIndex, items]); // Handler for download button const handleDownload = useCallback(() => { const currentItem = getCurrentItem(); if (currentItem && onDownload) { onDownload(currentItem); } }, [getCurrentItem, onDownload]); // Handler for share button const handleShare = useCallback(() => { const currentItem = getCurrentItem(); if (currentItem && onShare) { onShare(currentItem); } }, [getCurrentItem, onShare]); // Render function for each item in the grid gallery const renderGalleryItem = useCallback( ({ item, index }: { item: GalleryItem; index: number }) => ( handleItemPress(item, index)} > {/* Render custom overlay if provided */} {renderCustomOverlay && renderCustomOverlay(item, index)} {/* Display title and description if enabled */} {(showTitles || showDescriptions) && ( {showTitles && item.title && ( {item.title} )} {showDescriptions && item.description && ( {item.description} )} )} ), [ itemWidth, aspectRatio, borderRadius, handleItemPress, renderCustomOverlay, showTitles, showDescriptions, textColor, mutedColor, ] ); // Render function for each item in the fullscreen FlatList const renderFullscreenItem = useCallback( ({ item, index }: { item: GalleryItem; index: number }) => ( ), [enableZoom, selectedIndex] ); // Render controls for the fullscreen modal (top and bottom bars) const renderFullscreenControls = () => { const currentItem = getCurrentItem(); return ( {/* Top controls (share, download, close) */} {enableDownload && onDownload && ( )} {enableShare && onShare && ( )} {/* Bottom controls (page, title, description, thumbnails) */} {showPages && ( {selectedIndex + 1} of {items.length} )} {currentItem?.title && ( {currentItem.title} )} {currentItem?.description && ( {currentItem.description} )} {/* Horizontal FlatList for thumbnails */} ( handleThumbnailPress(index)} > )} keyExtractor={(item) => item.id} horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.thumbnailContainer} ItemSeparatorComponent={() => } // Spacing between thumbnails getItemLayout={(data, index) => ({ length: 48, // Fixed item length for layout calculation offset: 56 * index, // Offset for each item (item length + separator width) index, })} /> ); }; // Render empty state if no items are provided if (items.length === 0) { return ( No images to display ); } return ( // GestureHandlerRootView is required for React Native Gesture Handler to work {/* ScrollView for the main gallery grid */} { const { width } = event.nativeEvent.layout; setContainerWidth(width); }} > {/* Render each gallery item */} {items.map((item, index) => renderGalleryItem({ item, index }))} {/* Modal for fullscreen image view */} {/* GestureHandlerRootView for gestures within the modal */} {/* FlatList for horizontal swiping of fullscreen images */} item.id} horizontal pagingEnabled // Enables snap-to-page behavior for horizontal swiping showsHorizontalScrollIndicator={false} onViewableItemsChanged={onViewableItemsChanged} // Detect when current image changes viewabilityConfig={viewabilityConfig} getItemLayout={(data, index) => ({ length: screenWidth, // Each item takes full screen width offset: screenWidth * index, index, })} scrollEnabled={flatListScrollEnabled} // Control FlatList scrolling based on zoom state removeClippedSubviews={false} // Important for images that are partially off-screen due to zoom initialNumToRender={3} maxToRenderPerBatch={3} windowSize={21} /> {/* Render fullscreen controls overlay */} {renderFullscreenControls()} ); } // Stylesheet for the component const styles = StyleSheet.create({ container: { flex: 1, }, grid: { flexDirection: 'row', flexWrap: 'wrap', }, gridImage: { flex: 1, }, itemInfo: { padding: 8, }, emptyState: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 32, borderRadius: BORDER_RADIUS, margin: 16, }, imageContainer: { flex: 1, width: screenWidth, height: screenHeight, justifyContent: 'center', alignItems: 'center', }, fullscreenImage: { width: screenWidth, height: screenHeight, }, fullscreenControls: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, // Ensure controls don't block interaction with the image itself unless explicitly on a button pointerEvents: 'box-none', }, topControls: { position: 'absolute', top: 0, left: 0, right: 0, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingTop: 56, // Adjust for safe area (notch, status bar) paddingHorizontal: 16, paddingBottom: 16, }, topRightControls: { gap: 8, flexDirection: 'row', }, bottomControls: { position: 'absolute', bottom: 0, left: 0, right: 0, padding: 16, paddingBottom: 46, // Adjust for safe area (home indicator) }, thumbnailContainer: { paddingHorizontal: 16, alignItems: 'center', // Vertically center thumbnails }, thumbnailItem: { width: 40, height: 40, borderRadius: 8, borderWidth: 1, overflow: 'hidden', borderColor: 'transparent', }, thumbnailImage: { width: '100%', height: '100%', }, });