From 27bc812b1a48750ce3ab4f8c68223ddadd38cff3 Mon Sep 17 00:00:00 2001 From: Damien Dulac Date: Wed, 28 May 2025 09:59:10 +0200 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=F0=9F=90=9B=20zoom=20button=20alrea?= =?UTF-8?q?dy=20hidden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- src/Zoom/index.tsx | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 67a041d..feb9d03 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fidme/react-native-image-gallery", - "version": "1.7.2", + "version": "1.7.3", "access": "public", "description": "React Native Image Gallery with Thumbnails", "main": "lib/commonjs/index.js", diff --git a/src/Zoom/index.tsx b/src/Zoom/index.tsx index 067dbe8..3faa5b3 100644 --- a/src/Zoom/index.tsx +++ b/src/Zoom/index.tsx @@ -473,7 +473,7 @@ export default function Zoom( - + Date: Thu, 19 Feb 2026 15:47:00 +0100 Subject: [PATCH 2/3] feat: add vertical support --- package.json | 2 +- src/ImageGallery.tsx | 353 +++++------------------ src/Zoom/index.tsx | 8 +- src/core/ImagePreview.tsx | 46 +++ src/core/Thumbnail.tsx | 43 +++ src/core/ZoomContainer.tsx | 148 ++++++++++ src/core/index.ts | 11 + src/core/useGalleryState.ts | 64 +++++ src/core/useZoomGesture.ts | 393 ++++++++++++++++++++++++++ src/horizontal/HorizontalGallery.tsx | 223 +++++++++++++++ src/horizontal/index.ts | 3 + src/horizontal/useHorizontalScroll.ts | 143 ++++++++++ src/index.ts | 54 +++- src/types.ts | 198 ++++++++++--- src/vertical/VerticalFeed.tsx | 152 ++++++++++ src/vertical/index.ts | 3 + src/vertical/useVerticalScroll.ts | 63 +++++ 17 files changed, 1592 insertions(+), 315 deletions(-) create mode 100644 src/core/ImagePreview.tsx create mode 100644 src/core/Thumbnail.tsx create mode 100644 src/core/ZoomContainer.tsx create mode 100644 src/core/index.ts create mode 100644 src/core/useGalleryState.ts create mode 100644 src/core/useZoomGesture.ts create mode 100644 src/horizontal/HorizontalGallery.tsx create mode 100644 src/horizontal/index.ts create mode 100644 src/horizontal/useHorizontalScroll.ts create mode 100644 src/vertical/VerticalFeed.tsx create mode 100644 src/vertical/index.ts create mode 100644 src/vertical/useVerticalScroll.ts diff --git a/package.json b/package.json index feb9d03..c015d24 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fidme/react-native-image-gallery", - "version": "1.7.3", + "version": "2.0.0-beta.1", "access": "public", "description": "React Native Image Gallery with Thumbnails", "main": "lib/commonjs/index.js", diff --git a/src/ImageGallery.tsx b/src/ImageGallery.tsx index 2003968..76f7f4c 100644 --- a/src/ImageGallery.tsx +++ b/src/ImageGallery.tsx @@ -1,293 +1,94 @@ -import React, { useEffect, useRef, useState, useCallback } from 'react'; -import { - Dimensions, - FlatList, - Image, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native'; -import Animated, { - useAnimatedRef, - useDerivedValue, - useSharedValue, -} from 'react-native-reanimated'; -import { ImageObject, IProps, RenderImageProps } from './types'; -import ImagePreview from './ImagePreview'; -import Zoom from './Zoom'; +import React from 'react'; +import { ImageGalleryProps, HorizontalGalleryProps, VerticalFeedProps } from './types'; +import { HorizontalGallery } from './horizontal'; +import { VerticalFeed } from './vertical'; -const { width: deviceWidth } = Dimensions.get('window'); - -const ImageGallery = (props: IProps) => { +const ImageGallery = (props: ImageGalleryProps) => { const { - hideThumbs = false, + mode, + horizontal, + // Legacy prop mapping + enableManualZoom, + onPressPreviewImage, + // Base props images, initialIndex, + resizeMode, + onPageChange, renderCustomImage, - renderCustomThumb, - renderFooterComponent, renderHeaderComponent, - resizeMode = "contain", - thumbColor = "#d9b44a", - thumbResizeMode = "cover", - thumbSize = 48, - thumbOffset= 10, + renderFooterComponent, + // Horizontal-specific props + hideThumbs, + thumbColor, + thumbSize, + thumbOffset, + thumbResizeMode, + renderCustomThumb, + disableSwipe, + autoScroll, + disableAutoScroll, + close, + // Vertical-specific props onEndReached, - onPressPreviewImage, - onPageChange, - autoScroll = 0, - disableAutoScroll = false, - enableManualZoom = false, + onEndReachedThreshold, + ListHeaderComponent, + contentContainerStyle, + // New props + enableZoom, + onPressImage, } = props; - const [activeIndex, setActiveIndex] = useState(0); - const [autoScrollActive, setAutoScrollActive] = useState(autoScroll > 0); - const isScrolling = useSharedValue(false); - const isManualZoomEnabled = useDerivedValue( - () => !autoScrollActive && enableManualZoom && !isScrolling.value, - [autoScrollActive, enableManualZoom] - ); - - const topRef = useAnimatedRef(); - const bottomRef = useRef(null); - - const keyExtractorThumb = (item: ImageObject, index: number) => - item && item.id ? item.id.toString() : index.toString(); - const keyExtractorImage = (item: ImageObject, index: number) => - item && item.id ? item.id.toString() : index.toString(); - - const scrollToIndex = (i: number, scrollTopView: boolean = false) => { - const isValidIndex = Number.isFinite(i); - - if (!isValidIndex) { - setAutoScrollActive(false); - } - - if (isValidIndex && i !== activeIndex) { - onPageChange?.(i); - setActiveIndex(i); - - if (topRef?.current && scrollTopView) { - topRef.current.scrollToIndex({ - animated: true, - index: i, - }); - } - if (bottomRef?.current) { - if (i * (thumbSize + 10) - thumbSize / 2 > deviceWidth / 2) { - bottomRef?.current?.scrollToIndex({ - animated: true, - index: i, - }); - } else { - bottomRef?.current?.scrollToIndex({ - animated: true, - index: 0, - }); - } - } - } - }; - - const handlePressPreview = (item: ImageObject) => { - setAutoScrollActive(false); - onPressPreviewImage?.(item); - }; - - const renderItem = ({ item, index }: RenderImageProps) => { - return ( - - ); - }; - - const handleImagePreviewZoomBegin = () => { - setAutoScrollActive(false); - }; - - const renderThumb = ({ item, index }: RenderImageProps) => { - return ( - scrollToIndex(index, true)} - activeOpacity={0.8} - > - {renderCustomThumb ? ( - renderCustomThumb(item, index, activeIndex === index) - ) : ( - - )} - - ); - }; - - const onMomentumEnd = (e: any) => { - const { x } = e.nativeEvent.contentOffset; - scrollToIndex(Math.round(x / deviceWidth)); - }; - - useEffect(() => { - let autoScrollTimer: number; - - if (autoScrollActive && !disableAutoScroll) { - autoScrollTimer = setInterval(() => { - const nextIndex = (activeIndex + 1) % images.length; - scrollToIndex(nextIndex, true); - if (nextIndex === 0) { - setAutoScrollActive(false); - } - }, autoScroll); - } - - return () => { - clearInterval(autoScrollTimer); + // Determine effective mode: new 'mode' prop takes precedence over deprecated 'horizontal' + const effectiveMode = mode ?? (horizontal === false ? 'vertical' : 'horizontal'); + + // Map legacy props to new props + const effectiveEnableZoom = enableZoom ?? enableManualZoom; + const effectiveOnPressImage = onPressImage ?? onPressPreviewImage; + + if (effectiveMode === 'vertical') { + const verticalProps: VerticalFeedProps = { + images, + initialIndex, + resizeMode, + enableZoom: effectiveEnableZoom, + onPageChange, + onPressImage: effectiveOnPressImage, + renderCustomImage, + renderHeaderComponent, + renderFooterComponent, + onEndReached, + onEndReachedThreshold, + ListHeaderComponent, + contentContainerStyle, }; - }, [activeIndex, autoScrollActive, disableAutoScroll]); - - useEffect(() => { - if (initialIndex) { - onPageChange?.(initialIndex); - setActiveIndex(initialIndex); - } else { - onPageChange?.(0); - setActiveIndex(0); - } - }, []); - - const getImageLayout = useCallback( - (_, index) => { - return { - index, - length: deviceWidth, - offset: deviceWidth * index, - }; - }, - [images] - ); - const getThumbLayout = useCallback( - (_, index) => { - return { - index, - length: thumbSize, - offset: thumbSize * index + thumbOffset * index, - }; - }, - [images] - ); + return ; + } - const handleManualScroll = () => { - isScrolling.value = true; - setAutoScrollActive(false); - }; - - const onScrollEnd = () => { - isScrolling.value = false; + const horizontalProps: HorizontalGalleryProps = { + images, + initialIndex, + resizeMode, + enableZoom: effectiveEnableZoom, + onPageChange, + onPressImage: effectiveOnPressImage, + renderCustomImage, + renderHeaderComponent, + renderFooterComponent, + hideThumbs, + thumbColor, + thumbSize, + thumbOffset, + thumbResizeMode, + renderCustomThumb, + disableSwipe, + autoScroll, + disableAutoScroll, + close, }; - return ( - - {renderHeaderComponent ? ( - - {renderHeaderComponent(images?.[activeIndex], activeIndex)} - - ) : null} - - - - - - {hideThumbs ? null : ( - - ( - - )} - onEndReachedThreshold={0.2} - onEndReached={onEndReached} - style={styles.bottomFlatlist} - onScrollBeginDrag={handleManualScroll} - /> - - )} - {renderFooterComponent ? ( - - {renderFooterComponent(images[activeIndex], activeIndex)} - - ) : null} - - ); + return ; }; -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - header: { - width: '100%', - }, - footer: { - bottom: 0, - position: 'absolute', - width: '100%', - }, - activeThumb: { - borderWidth: 3, - }, - thumb: { - borderRadius: 12, - }, - thumbnailListContainer: { - paddingHorizontal: 10, - }, - bottomFlatlist: { - paddingVertical: 20, - }, -}); - export default ImageGallery; diff --git a/src/Zoom/index.tsx b/src/Zoom/index.tsx index 3faa5b3..30b5dd9 100644 --- a/src/Zoom/index.tsx +++ b/src/Zoom/index.tsx @@ -44,7 +44,7 @@ interface UseZoomGestureProps { } export function useZoomGesture(props: UseZoomGestureProps = {}): { - zoomGesture: typeof Gesture; + zoomGesture: ReturnType; contentContainerAnimatedStyle: any; onLayout: (event: LayoutChangeEvent) => void; onLayoutContent: (event: LayoutChangeEvent) => void; @@ -338,7 +338,7 @@ export function useZoomGesture(props: UseZoomGestureProps = {}): { ) .onTouchesMove( (e: GestureTouchEvent, state: GestureStateManagerType): void => { - if ([State.UNDETERMINED, State.BEGAN].includes(e.state)) + if (e.state === State.UNDETERMINED || e.state === State.BEGAN) if (isZoomedIn.value || e.numberOfTouches === 2) state.activate(); else state.fail(); } @@ -408,6 +408,8 @@ export function useZoomGesture(props: UseZoomGestureProps = {}): { handleZoom, currentIconId, isDragging, + zoomIn, + zoomOut, }; } @@ -467,7 +469,7 @@ export default function Zoom( style={[contentContainerAnimatedStyle, contentContainerStyle]} onLayout={onLayoutContent} > - {React.cloneElement(children, { + {React.cloneElement(children as React.ReactElement, { animatedProps: childrenAnimatedProps, })} diff --git a/src/core/ImagePreview.tsx b/src/core/ImagePreview.tsx new file mode 100644 index 0000000..5370297 --- /dev/null +++ b/src/core/ImagePreview.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { + Dimensions, + Image, + StyleSheet, + TouchableWithoutFeedback, + View, +} from 'react-native'; +import { ImagePreviewProps } from '../types'; + +const { width } = Dimensions.get('window'); + +const ImagePreview = ({ + index, + isSelected, + item, + renderCustomImage, + resizeMode, + onPress, +}: ImagePreviewProps) => { + return ( + onPress?.(item)}> + + {renderCustomImage ? ( + renderCustomImage(item, index, isSelected) + ) : ( + + )} + + + ); +}; + +const styles = StyleSheet.create({ + containerStyle: {}, + image: { + height: '100%', + width, + }, +}); + +export default ImagePreview; diff --git a/src/core/Thumbnail.tsx b/src/core/Thumbnail.tsx new file mode 100644 index 0000000..b0b1991 --- /dev/null +++ b/src/core/Thumbnail.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { Image, StyleSheet, TouchableOpacity } from 'react-native'; +import { ThumbnailProps } from '../types'; + +const Thumbnail = ({ + item, + index, + isSelected, + thumbSize, + thumbColor, + thumbResizeMode, + onPress, + renderCustomThumb, +}: ThumbnailProps) => { + return ( + onPress(index)} activeOpacity={0.8}> + {renderCustomThumb ? ( + renderCustomThumb(item, index, isSelected) + ) : ( + + )} + + ); +}; + +const styles = StyleSheet.create({ + thumb: { + borderRadius: 12, + }, + activeThumb: { + borderWidth: 3, + }, +}); + +export default Thumbnail; diff --git a/src/core/ZoomContainer.tsx b/src/core/ZoomContainer.tsx new file mode 100644 index 0000000..f422f2d --- /dev/null +++ b/src/core/ZoomContainer.tsx @@ -0,0 +1,148 @@ +import React, { PropsWithChildren } from 'react'; +import { + StyleProp, + StyleSheet, + View, + ViewProps, + TouchableOpacity, +} from 'react-native'; +import Animated, { + AnimatableValue, + AnimationCallback, + DerivedValue, + useAnimatedProps, + useAnimatedStyle, + withDelay, + withTiming, +} from 'react-native-reanimated'; +import { GestureDetector } from 'react-native-gesture-handler'; +import { useZoomGesture } from './useZoomGesture'; + +const iconsButton = { + 1: require('../assets/zoomIn.png'), + 2: require('../assets/zoomOut.png'), +}; + +export interface ZoomContainerProps { + style?: StyleProp; + contentContainerStyle?: StyleProp; + animationConfig?: object; + onZoomBegin?: () => void; + isManualZoomEnabled: DerivedValue; + + animationFunction?( + toValue: T, + userConfig?: object, + callback?: AnimationCallback + ): T; +} + +export default function ZoomContainer( + props: PropsWithChildren +): React.ReactElement { + const { + isManualZoomEnabled, + style, + contentContainerStyle, + children, + ...rest + } = props; + + const { + zoomGesture, + onLayout, + onLayoutContent, + contentContainerAnimatedStyle, + lastScale, + handleZoom, + currentIconId, + isDragging, + } = useZoomGesture({ + ...rest, + }); + + const getIconOpacityStyle = (id: string) => { + return useAnimatedStyle(() => ({ + opacity: id.toString() === currentIconId.value.toString() ? 1 : 0, + })); + }; + + const manualZoomButtonAnimatedStyle = useAnimatedStyle(() => { + const hideButton = isDragging.value || !isManualZoomEnabled.value; + return { + opacity: withDelay(hideButton ? 0 : 1000, withTiming(hideButton ? 0 : 1)), + }; + }); + + const childrenAnimatedProps = useAnimatedProps(() => { + return { + scrollEnabled: lastScale.value <= 1.2, + }; + }); + + return ( + <> + + + + {React.cloneElement(children as React.ReactElement, { + animatedProps: childrenAnimatedProps, + })} + + + + + + {Object.entries(iconsButton).map(icon => ( + + ))} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + overflow: 'hidden', + }, + zoomButtonWrapper: { + position: 'absolute', + right: 40, + bottom: 40, + }, + zoomButtonContainer: { + backgroundColor: '#2E2B2B', + overflow: 'hidden', + borderRadius: 50, + padding: 8, + width: 56, + height: 56, + alignItems: 'center', + justifyContent: 'center', + }, + zoomButtonImage: { + width: 30, + height: 30, + tintColor: 'white', + position: 'absolute', + }, +}); diff --git a/src/core/index.ts b/src/core/index.ts new file mode 100644 index 0000000..04d4993 --- /dev/null +++ b/src/core/index.ts @@ -0,0 +1,11 @@ +export { useGalleryState } from './useGalleryState'; +export type { UseGalleryStateProps, UseGalleryStateReturn } from './useGalleryState'; + +export { useZoomGesture } from './useZoomGesture'; +export type { UseZoomGestureProps, UseZoomGestureReturn } from './useZoomGesture'; + +export { default as ZoomContainer } from './ZoomContainer'; +export type { ZoomContainerProps } from './ZoomContainer'; + +export { default as ImagePreview } from './ImagePreview'; +export { default as Thumbnail } from './Thumbnail'; diff --git a/src/core/useGalleryState.ts b/src/core/useGalleryState.ts new file mode 100644 index 0000000..4a53703 --- /dev/null +++ b/src/core/useGalleryState.ts @@ -0,0 +1,64 @@ +import { useState, useCallback, useRef, useEffect } from 'react'; +import { useSharedValue, SharedValue } from 'react-native-reanimated'; +import { ImageObject } from '../types'; + +export interface UseGalleryStateProps { + images: ImageObject[]; + initialIndex?: number; + onPageChange?: (index: number) => void; +} + +export interface UseGalleryStateReturn { + activeIndex: number; + activeIndexRef: React.MutableRefObject; + setActiveIndex: React.Dispatch>; + goToIndex: (index: number) => void; + isScrolling: SharedValue; + currentImage: ImageObject | undefined; +} + +export function useGalleryState(props: UseGalleryStateProps): UseGalleryStateReturn { + const { images, initialIndex = 0, onPageChange } = props; + + const [activeIndex, setActiveIndex] = useState(initialIndex); + const activeIndexRef = useRef(initialIndex); + const isScrolling = useSharedValue(false); + + // Sync activeIndexRef with activeIndex + useEffect(() => { + activeIndexRef.current = activeIndex; + }, [activeIndex]); + + // Initialize on mount + useEffect(() => { + if (initialIndex) { + onPageChange?.(initialIndex); + setActiveIndex(initialIndex); + } else { + onPageChange?.(0); + setActiveIndex(0); + } + }, []); + + const goToIndex = useCallback( + (index: number) => { + const isValidIndex = Number.isFinite(index) && index >= 0 && index < images.length; + + if (isValidIndex && index !== activeIndexRef.current) { + activeIndexRef.current = index; + setActiveIndex(index); + onPageChange?.(index); + } + }, + [images.length, onPageChange] + ); + + return { + activeIndex, + activeIndexRef, + setActiveIndex, + goToIndex, + isScrolling, + currentImage: images[activeIndex], + }; +} diff --git a/src/core/useZoomGesture.ts b/src/core/useZoomGesture.ts new file mode 100644 index 0000000..7218421 --- /dev/null +++ b/src/core/useZoomGesture.ts @@ -0,0 +1,393 @@ +import { useCallback, useMemo, useRef } from 'react'; +import { LayoutChangeEvent } from 'react-native'; +import { + runOnJS, + SharedValue, + useAnimatedStyle, + useDerivedValue, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; +import { + Gesture, + GestureStateChangeEvent, + GestureTouchEvent, + GestureUpdateEvent, + PanGestureHandlerEventPayload, + PinchGestureHandlerEventPayload, + State, +} from 'react-native-gesture-handler'; +import { GestureStateManagerType } from 'react-native-gesture-handler/lib/typescript/handlers/gestures/gestureStateManager'; + +export interface UseZoomGestureProps { + animationFunction?: (toValue: number, config?: object) => any; + animationConfig?: object; + onZoomBegin?: () => void; +} + +export interface UseZoomGestureReturn { + zoomGesture: ReturnType; + contentContainerAnimatedStyle: ReturnType; + onLayout: (event: LayoutChangeEvent) => void; + onLayoutContent: (event: LayoutChangeEvent) => void; + zoomOut: () => void; + zoomIn: () => void; + currentIconId: SharedValue; + lastScale: SharedValue; + handleZoom: () => void; + isDragging: SharedValue; +} + +export function useZoomGesture(props: UseZoomGestureProps = {}): UseZoomGestureReturn { + const { + animationFunction = withTiming, + animationConfig, + onZoomBegin, + } = props; + + const baseScale = useSharedValue(1); + const pinchScale = useSharedValue(1); + const lastScale = useSharedValue(1); + const isDragging = useSharedValue(false); + const isZoomedIn = useDerivedValue(() => { + const isZoomed = lastScale.value > 1; + + if (isZoomed && onZoomBegin) { + runOnJS(onZoomBegin)(); + } + + return isZoomed; + }); + + const currentIconId = useDerivedValue(() => { + return lastScale.value >= 2.5 ? 2 : 1; + }); + + const zoomGestureLastTime = useSharedValue(0); + const containerDimensions = useSharedValue({ width: 0, height: 0 }); + const contentDimensions = useSharedValue({ width: 1, height: 1 }); + + const translateX = useSharedValue(0); + const translateY = useSharedValue(0); + const lastOffsetX = useSharedValue(0); + const lastOffsetY = useSharedValue(0); + const panStartOffsetX = useSharedValue(0); + const panStartOffsetY = useSharedValue(0); + + const handlePanOutsideTimeoutId: React.MutableRefObject< + ReturnType | undefined + > = useRef(); + + const withAnimation = useCallback( + (toValue: number, config?: object) => { + 'worklet'; + + return animationFunction(toValue, { + duration: 350, + ...config, + ...animationConfig, + }); + }, + [animationFunction, animationConfig] + ); + + const getContentContainerSize = useCallback(() => { + return { + width: containerDimensions.value.width, + height: + (contentDimensions.value.height * containerDimensions.value.width) / + contentDimensions.value.width, + }; + }, [containerDimensions]); + + const zoomIn = useCallback((): void => { + let newScale = 2.5; + + lastScale.value = newScale; + + baseScale.value = withAnimation(newScale); + pinchScale.value = withAnimation(1); + }, [ + baseScale, + pinchScale, + lastScale, + withAnimation, + ]); + + const zoomOut = useCallback((): void => { + const newScale = 1; + lastScale.value = newScale; + + baseScale.value = withAnimation(newScale); + pinchScale.value = withAnimation(1); + + const newOffsetX = 0; + lastOffsetX.value = newOffsetX; + + const newOffsetY = 0; + lastOffsetY.value = newOffsetY; + + translateX.value = withAnimation(newOffsetX); + translateY.value = withAnimation(newOffsetY); + }, [ + baseScale, + pinchScale, + lastOffsetX, + lastOffsetY, + translateX, + translateY, + lastScale, + withAnimation, + ]); + + const handlePanOutside = useCallback((): void => { + if (handlePanOutsideTimeoutId.current !== undefined) + clearTimeout(handlePanOutsideTimeoutId.current); + + handlePanOutsideTimeoutId.current = setTimeout((): void => { + const { width, height } = getContentContainerSize(); + const maxOffset = { + x: + width * lastScale.value < containerDimensions.value.width + ? 0 + : (width * lastScale.value - containerDimensions.value.width) / + 2 / + lastScale.value, + y: + height * lastScale.value < containerDimensions.value.height + ? 0 + : (height * lastScale.value - containerDimensions.value.height) / + 2 / + lastScale.value, + }; + + const isPanedXOutside = + lastOffsetX.value > maxOffset.x || lastOffsetX.value < -maxOffset.x; + if (isPanedXOutside) { + const newOffsetX = lastOffsetX.value >= 0 ? maxOffset.x : -maxOffset.x; + lastOffsetX.value = newOffsetX; + + translateX.value = withAnimation(newOffsetX); + } else { + translateX.value = lastOffsetX.value; + } + + const isPanedYOutside = + lastOffsetY.value > maxOffset.y || lastOffsetY.value < -maxOffset.y; + if (isPanedYOutside) { + const newOffsetY = lastOffsetY.value >= 0 ? maxOffset.y : -maxOffset.y; + lastOffsetY.value = newOffsetY; + + translateY.value = withAnimation(newOffsetY); + } else { + translateY.value = lastOffsetY.value; + } + }, 10); + }, [ + lastOffsetX, + lastOffsetY, + lastScale, + translateX, + translateY, + containerDimensions, + getContentContainerSize, + withAnimation, + ]); + + const handleZoom = useCallback(() => { + if (lastScale.value >= 2.5) { + zoomOut(); + } else { + zoomIn(); + } + }, [zoomIn, zoomOut]); + + const onLayout = useCallback( + ({ + nativeEvent: { + layout: { width, height }, + }, + }: LayoutChangeEvent): void => { + containerDimensions.value = { + width, + height, + }; + }, + [containerDimensions] + ); + + const onLayoutContent = useCallback( + ({ + nativeEvent: { + layout: { width, height }, + }, + }: LayoutChangeEvent): void => { + contentDimensions.value = { + width, + height, + }; + }, + [contentDimensions] + ); + + const onPinchEnd = useCallback( + (scale: number): void => { + const newScale = lastScale.value * scale; + lastScale.value = newScale; + if (newScale > 1) { + baseScale.value = newScale; + pinchScale.value = 1; + + handlePanOutside(); + } else { + zoomOut(); + } + }, + [lastScale, baseScale, pinchScale, handlePanOutside, zoomOut] + ); + + const updateZoomGestureLastTime = useCallback((): void => { + 'worklet'; + + zoomGestureLastTime.value = Date.now(); + }, [zoomGestureLastTime]); + + const zoomGesture = useMemo(() => { + const tapGesture = Gesture.Tap() + .numberOfTaps(2) + .onStart(() => { + updateZoomGestureLastTime(); + }) + .onEnd(() => { + updateZoomGestureLastTime(); + runOnJS(handleZoom)(); + }); + + const panGesture = Gesture.Pan() + .onStart( + (event: GestureUpdateEvent): void => { + updateZoomGestureLastTime(); + + const { translationX, translationY } = event; + + panStartOffsetX.value = translationX; + panStartOffsetY.value = translationY; + + isDragging.value = true; + } + ) + .onUpdate( + (event: GestureUpdateEvent): void => { + updateZoomGestureLastTime(); + + let { translationX, translationY } = event; + + translationX -= panStartOffsetX.value; + translationY -= panStartOffsetY.value; + + translateX.value = lastOffsetX.value + translationX / lastScale.value; + translateY.value = lastOffsetY.value + translationY / lastScale.value; + } + ) + .onEnd( + ( + event: GestureStateChangeEvent + ): void => { + updateZoomGestureLastTime(); + + let { translationX, translationY } = event; + + translationX -= panStartOffsetX.value; + translationY -= panStartOffsetY.value; + + // SAVES LAST POSITION + lastOffsetX.value = + lastOffsetX.value + translationX / lastScale.value; + lastOffsetY.value = + lastOffsetY.value + translationY / lastScale.value; + + isDragging.value = false; + + runOnJS(handlePanOutside)(); + } + ) + .onTouchesMove( + (e: GestureTouchEvent, state: GestureStateManagerType): void => { + if (e.state === State.UNDETERMINED || e.state === State.BEGAN) + if (isZoomedIn.value || e.numberOfTouches === 2) state.activate(); + else state.fail(); + } + ) + .minDistance(0) + .minPointers(2) + .maxPointers(2); + + const pinchGesture = Gesture.Pinch() + .onStart(() => { + updateZoomGestureLastTime(); + isDragging.value = true; + }) + .onUpdate( + ({ + scale, + }: GestureUpdateEvent): void => { + updateZoomGestureLastTime(); + + pinchScale.value = scale; + } + ) + .onEnd( + ({ + scale, + }: GestureUpdateEvent): void => { + updateZoomGestureLastTime(); + + pinchScale.value = scale; + isDragging.value = false; + + runOnJS(onPinchEnd)(scale); + } + ); + + return Gesture.Exclusive( + Gesture.Simultaneous(pinchGesture, panGesture), + tapGesture + ); + }, [ + handlePanOutside, + lastOffsetX, + lastOffsetY, + handleZoom, + onPinchEnd, + pinchScale, + translateX, + translateY, + lastScale, + isZoomedIn, + updateZoomGestureLastTime, + panStartOffsetX, + panStartOffsetY, + isDragging, + ]); + + const contentContainerAnimatedStyle = useAnimatedStyle(() => ({ + transform: [ + { scale: baseScale.value * pinchScale.value }, + { translateX: translateX.value }, + { translateY: translateY.value }, + ], + })); + + return { + zoomGesture, + contentContainerAnimatedStyle, + onLayout, + onLayoutContent, + lastScale, + handleZoom, + currentIconId, + isDragging, + zoomIn, + zoomOut, + }; +} diff --git a/src/horizontal/HorizontalGallery.tsx b/src/horizontal/HorizontalGallery.tsx new file mode 100644 index 0000000..31c0087 --- /dev/null +++ b/src/horizontal/HorizontalGallery.tsx @@ -0,0 +1,223 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import { FlatList, StyleSheet, View } from 'react-native'; +import Animated, { useDerivedValue } from 'react-native-reanimated'; +import { HorizontalGalleryProps, ImageObject, RenderImageProps } from '../types'; +import { useGalleryState, ZoomContainer, ImagePreview, Thumbnail } from '../core'; +import { useHorizontalScroll } from './useHorizontalScroll'; + +const HorizontalGallery = (props: HorizontalGalleryProps) => { + const { + hideThumbs = false, + images, + initialIndex, + renderCustomImage, + renderCustomThumb, + renderFooterComponent, + renderHeaderComponent, + resizeMode = 'contain', + thumbColor = '#d9b44a', + thumbResizeMode = 'cover', + thumbSize = 48, + thumbOffset = 10, + onPressImage, + onPageChange, + autoScroll = 0, + disableAutoScroll = false, + enableZoom = false, + } = props; + + const [autoScrollActive, setAutoScrollActive] = useState(autoScroll > 0); + + const { + activeIndex, + goToIndex, + isScrolling, + currentImage, + } = useGalleryState({ + images, + initialIndex, + onPageChange, + }); + + const isManualZoomEnabled = useDerivedValue( + () => !autoScrollActive && enableZoom && !isScrolling.value, + [autoScrollActive, enableZoom] + ); + + const handleAutoScrollStop = useCallback(() => { + setAutoScrollActive(false); + }, []); + + const { + topRef, + bottomRef, + scrollToIndex, + onMomentumEnd, + handleManualScroll, + onScrollEnd, + getImageLayout, + getThumbLayout, + } = useHorizontalScroll({ + activeIndex, + thumbSize, + thumbOffset, + isScrolling, + goToIndex, + onAutoScrollStop: handleAutoScrollStop, + }); + + const keyExtractor = useCallback( + (item: ImageObject, index: number) => + item && item.id ? item.id.toString() : index.toString(), + [] + ); + + const handlePressPreview = useCallback( + (item: ImageObject) => { + setAutoScrollActive(false); + onPressImage?.(item); + }, + [onPressImage] + ); + + const handleZoomBegin = useCallback(() => { + setAutoScrollActive(false); + }, []); + + const renderItem = useCallback( + ({ item, index }: RenderImageProps) => { + return ( + + ); + }, + [activeIndex, resizeMode, renderCustomImage, handlePressPreview] + ); + + const renderThumb = useCallback( + ({ item, index }: RenderImageProps) => { + return ( + scrollToIndex(i, true)} + renderCustomThumb={renderCustomThumb} + /> + ); + }, + [activeIndex, thumbSize, thumbColor, thumbResizeMode, renderCustomThumb, scrollToIndex] + ); + + // Auto-scroll effect + useEffect(() => { + let autoScrollTimer: ReturnType; + + if (autoScrollActive && !disableAutoScroll) { + autoScrollTimer = setInterval(() => { + const nextIndex = (activeIndex + 1) % images.length; + scrollToIndex(nextIndex, true); + if (nextIndex === 0) { + setAutoScrollActive(false); + } + }, autoScroll); + } + + return () => { + clearInterval(autoScrollTimer); + }; + }, [activeIndex, autoScrollActive, disableAutoScroll, autoScroll, images.length, scrollToIndex]); + + return ( + + {renderHeaderComponent ? ( + + {renderHeaderComponent(currentImage!, activeIndex)} + + ) : null} + + + + + + + + {hideThumbs ? null : ( + + } + onEndReachedThreshold={0.2} + style={styles.bottomFlatlist} + onScrollBeginDrag={handleManualScroll} + /> + + )} + + {renderFooterComponent ? ( + + {renderFooterComponent(currentImage!, activeIndex)} + + ) : null} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + content: { + flex: 1, + }, + header: { + width: '100%', + }, + footer: { + bottom: 0, + position: 'absolute', + width: '100%', + }, + thumbnailListContainer: { + paddingHorizontal: 10, + }, + bottomFlatlist: { + paddingVertical: 20, + }, +}); + +export default HorizontalGallery; diff --git a/src/horizontal/index.ts b/src/horizontal/index.ts new file mode 100644 index 0000000..05ffcc5 --- /dev/null +++ b/src/horizontal/index.ts @@ -0,0 +1,3 @@ +export { default as HorizontalGallery } from './HorizontalGallery'; +export { useHorizontalScroll } from './useHorizontalScroll'; +export type { UseHorizontalScrollProps, UseHorizontalScrollReturn } from './useHorizontalScroll'; diff --git a/src/horizontal/useHorizontalScroll.ts b/src/horizontal/useHorizontalScroll.ts new file mode 100644 index 0000000..545f65a --- /dev/null +++ b/src/horizontal/useHorizontalScroll.ts @@ -0,0 +1,143 @@ +import { useCallback, useRef } from 'react'; +import { Dimensions, FlatList } from 'react-native'; +import { SharedValue, useAnimatedRef } from 'react-native-reanimated'; +import { ImageObject } from '../types'; + +const { width: deviceWidth } = Dimensions.get('window'); + +export interface UseHorizontalScrollProps { + activeIndex: number; + thumbSize: number; + thumbOffset: number; + isScrolling: SharedValue; + goToIndex: (index: number) => void; + onAutoScrollStop?: () => void; +} + +export interface UseHorizontalScrollReturn { + topRef: any; + bottomRef: any; + scrollToIndex: (index: number, scrollTopView?: boolean) => void; + onMomentumEnd: (e: any) => void; + handleManualScroll: () => void; + onScrollEnd: () => void; + getImageLayout: (data: any, index: number) => { index: number; length: number; offset: number }; + getThumbLayout: (data: any, index: number) => { index: number; length: number; offset: number }; +} + +export function useHorizontalScroll(props: UseHorizontalScrollProps): UseHorizontalScrollReturn { + const { + activeIndex, + thumbSize, + thumbOffset, + isScrolling, + goToIndex, + onAutoScrollStop, + } = props; + + const topRef = useAnimatedRef>(); + const bottomRef = useRef(null); + + const scrollToIndex = useCallback( + (i: number, scrollTopView: boolean = false) => { + const isValidIndex = Number.isFinite(i); + + if (!isValidIndex) { + onAutoScrollStop?.(); + return; + } + + if (isValidIndex && i !== activeIndex) { + goToIndex(i); + + if (topRef?.current && scrollTopView) { + topRef.current.scrollToIndex({ + animated: true, + index: i, + }); + } + if (bottomRef?.current) { + if (i * (thumbSize + 10) - thumbSize / 2 > deviceWidth / 2) { + bottomRef?.current?.scrollToIndex({ + animated: true, + index: i, + }); + } else { + bottomRef?.current?.scrollToIndex({ + animated: true, + index: 0, + }); + } + } + } + }, + [activeIndex, thumbSize, goToIndex, onAutoScrollStop] + ); + + const onMomentumEnd = useCallback( + (e: any) => { + const { x } = e.nativeEvent.contentOffset; + const newIndex = Math.round(x / deviceWidth); + if (newIndex !== activeIndex) { + goToIndex(newIndex); + // Sync thumb list + if (bottomRef?.current) { + if (newIndex * (thumbSize + 10) - thumbSize / 2 > deviceWidth / 2) { + bottomRef?.current?.scrollToIndex({ + animated: true, + index: newIndex, + }); + } else { + bottomRef?.current?.scrollToIndex({ + animated: true, + index: 0, + }); + } + } + } + }, + [activeIndex, thumbSize, goToIndex] + ); + + const handleManualScroll = useCallback(() => { + isScrolling.value = true; + onAutoScrollStop?.(); + }, [isScrolling, onAutoScrollStop]); + + const onScrollEnd = useCallback(() => { + isScrolling.value = false; + }, [isScrolling]); + + const getImageLayout = useCallback( + (_: any, index: number) => { + return { + index, + length: deviceWidth, + offset: deviceWidth * index, + }; + }, + [] + ); + + const getThumbLayout = useCallback( + (_: any, index: number) => { + return { + index, + length: thumbSize, + offset: thumbSize * index + thumbOffset * index, + }; + }, + [thumbSize, thumbOffset] + ); + + return { + topRef, + bottomRef, + scrollToIndex, + onMomentumEnd, + handleManualScroll, + onScrollEnd, + getImageLayout, + getThumbLayout, + }; +} diff --git a/src/index.ts b/src/index.ts index 6a028d6..19ba7d1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,54 @@ +// Main component (wrapper) export { default as ImageGallery } from './ImageGallery'; -export { ImageObject } from './types'; + +// Mode-specific components +export { HorizontalGallery, useHorizontalScroll } from './horizontal'; +export { VerticalFeed, useVerticalScroll } from './vertical'; + +// Core components and hooks +export { + useGalleryState, + useZoomGesture, + ZoomContainer, + ImagePreview, + Thumbnail, +} from './core'; + +// Types +export type { + GalleryMode, + ImageObject, + ImageSource, + BaseGalleryProps, + HorizontalGalleryProps, + VerticalFeedProps, + ImageGalleryProps, + ImagePreviewProps, + ThumbnailProps, + HeaderProps, + FooterProps, + RenderImageProps, + GalleryState, + IProps, +} from './types'; + +// Core types +export type { + UseGalleryStateProps, + UseGalleryStateReturn, + UseZoomGestureProps, + UseZoomGestureReturn, + ZoomContainerProps, +} from './core'; + +// Horizontal types +export type { + UseHorizontalScrollProps, + UseHorizontalScrollReturn, +} from './horizontal'; + +// Vertical types +export type { + UseVerticalScrollProps, + UseVerticalScrollReturn, +} from './vertical'; diff --git a/src/types.ts b/src/types.ts index 2a2b5ae..e213028 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,29 +1,39 @@ import React from 'react'; -import { ImageResizeMode } from 'react-native'; +import { ImageResizeMode, StyleProp, ViewStyle } from 'react-native'; -export interface IProps { - close: () => void; - hideThumbs?: boolean; +// Gallery mode +export type GalleryMode = 'horizontal' | 'vertical'; + +// Image source type +export interface ImageSource { + uri: string; + headers?: { [key: string]: string }; +} + +// Image object +export interface ImageObject { + id?: string | number; + thumbnail?: { + source: ImageSource; + }; + source: ImageSource; +} + +// Render props types +export interface RenderImageProps { + item: ImageObject; + index: number; + resizeMode?: ImageResizeMode; +} + +// Base gallery props shared between modes +export interface BaseGalleryProps { images: ImageObject[]; initialIndex?: number; resizeMode?: ImageResizeMode; - thumbColor?: string; - thumbSize?: number; - thumbResizeMode?: ImageResizeMode; - thumbOffset?: number; - disableSwipe?: boolean; - onEndReached?: void; - onPressPreviewImage?: (item: ImageObject) => void; + enableZoom?: boolean; onPageChange?: (index: number) => void; - autoScroll?: number; - disableAutoScroll?: boolean; - enableManualZoom?: boolean; - - renderCustomThumb?: ( - item: ImageObject, - index: number, - isSelected: boolean - ) => React.ReactNode; + onPressImage?: (item: ImageObject) => void; renderCustomImage?: ( item: ImageObject, @@ -42,29 +52,87 @@ export interface IProps { ) => React.ReactNode; } +// Props specific to horizontal gallery mode +export interface HorizontalGalleryProps extends BaseGalleryProps { + // Thumbnails + hideThumbs?: boolean; + thumbColor?: string; + thumbSize?: number; + thumbOffset?: number; + thumbResizeMode?: ImageResizeMode; + renderCustomThumb?: ( + item: ImageObject, + index: number, + isSelected: boolean + ) => React.ReactNode; + + // Swipe & Auto-scroll + disableSwipe?: boolean; + autoScroll?: number; + disableAutoScroll?: boolean; + + // Close action + close?: () => void; +} + +// Props specific to vertical feed mode +export interface VerticalFeedProps extends BaseGalleryProps { + onEndReached?: () => void; + onEndReachedThreshold?: number; + ListHeaderComponent?: React.ReactNode; + contentContainerStyle?: StyleProp; +} + +// Main wrapper props (union of both modes with mode selector) +export interface ImageGalleryProps extends BaseGalleryProps { + mode?: GalleryMode; + + // Horizontal-specific (only used when mode='horizontal') + hideThumbs?: boolean; + thumbColor?: string; + thumbSize?: number; + thumbOffset?: number; + thumbResizeMode?: ImageResizeMode; + renderCustomThumb?: ( + item: ImageObject, + index: number, + isSelected: boolean + ) => React.ReactNode; + disableSwipe?: boolean; + autoScroll?: number; + disableAutoScroll?: boolean; + close?: () => void; + + // Vertical-specific (only used when mode='vertical') + onEndReached?: () => void; + onEndReachedThreshold?: number; + ListHeaderComponent?: React.ReactNode; + contentContainerStyle?: StyleProp; + + // Deprecated: use 'mode' instead + /** @deprecated Use mode='horizontal' or mode='vertical' instead */ + horizontal?: boolean; + + // Legacy props (kept for backwards compatibility) + /** @deprecated Use enableZoom instead */ + enableManualZoom?: boolean; + /** @deprecated Use onPressImage instead */ + onPressPreviewImage?: (item: ImageObject) => void; +} + +// Header component props export interface HeaderProps { currentIndex: number; item?: ImageObject; } +// Footer component props export interface FooterProps { currentIndex: number; total: number; } -export interface ImageObject { - id?: string | number; - thumbnail?: { - source: ImageSource; - }; - source: ImageSource; -} - -export interface ImageSource { - uri: string; - headers: object; -} - +// Image preview component props export interface ImagePreviewProps { index: number; isSelected: boolean; @@ -79,8 +147,70 @@ export interface ImagePreviewProps { ) => React.ReactNode; } -export interface RenderImageProps { +// Thumbnail component props +export interface ThumbnailProps { item: ImageObject; index: number; + isSelected: boolean; + thumbSize: number; + thumbColor: string; + thumbResizeMode: ImageResizeMode; + onPress: (index: number) => void; + renderCustomThumb?: ( + item: ImageObject, + index: number, + isSelected: boolean + ) => React.ReactNode; +} + +// Gallery state hook return type +export interface GalleryState { + activeIndex: number; + setActiveIndex: (index: number) => void; + goToIndex: (index: number, options?: { animated?: boolean }) => void; + currentImage: ImageObject | undefined; +} + +// Legacy IProps for backwards compatibility +/** @deprecated Use ImageGalleryProps instead */ +export interface IProps { + close?: () => void; + hideThumbs?: boolean; + images: ImageObject[]; + initialIndex?: number; resizeMode?: ImageResizeMode; + thumbColor?: string; + thumbSize?: number; + thumbResizeMode?: ImageResizeMode; + thumbOffset?: number; + disableSwipe?: boolean; + onEndReached?: () => void; + onPressPreviewImage?: (item: ImageObject) => void; + onPageChange?: (index: number) => void; + autoScroll?: number; + disableAutoScroll?: boolean; + enableManualZoom?: boolean; + horizontal?: boolean; + + renderCustomThumb?: ( + item: ImageObject, + index: number, + isSelected: boolean + ) => React.ReactNode; + + renderCustomImage?: ( + item: ImageObject, + index: number, + isSelected: boolean + ) => React.ReactNode; + + renderHeaderComponent?: ( + item: ImageObject, + currentIndex: number + ) => React.ReactNode; + + renderFooterComponent?: ( + item: ImageObject, + currentIndex: number + ) => React.ReactNode; } diff --git a/src/vertical/VerticalFeed.tsx b/src/vertical/VerticalFeed.tsx new file mode 100644 index 0000000..919f866 --- /dev/null +++ b/src/vertical/VerticalFeed.tsx @@ -0,0 +1,152 @@ +import React, { useCallback } from 'react'; +import { FlatList, StyleSheet, View } from 'react-native'; +import Animated, { useDerivedValue } from 'react-native-reanimated'; +import { VerticalFeedProps, ImageObject, RenderImageProps } from '../types'; +import { useGalleryState, ZoomContainer, ImagePreview } from '../core'; +import { useVerticalScroll } from './useVerticalScroll'; + + +const VerticalFeed = (props: VerticalFeedProps) => { + const { + images, + initialIndex, + renderCustomImage, + renderFooterComponent, + renderHeaderComponent, + resizeMode = 'contain', + onPressImage, + onPageChange, + onEndReached, + onEndReachedThreshold = 0.5, + enableZoom = false, + contentContainerStyle, + } = props; + + const { + activeIndex, + activeIndexRef, + goToIndex, + isScrolling, + currentImage, + } = useGalleryState({ + images, + initialIndex, + onPageChange, + }); + + const isManualZoomEnabled = useDerivedValue( + () => enableZoom && !isScrolling.value, + [enableZoom] + ); + + const { + listRef, + viewabilityConfig, + onViewableItemsChanged, + handleManualScroll, + onScrollEnd, + } = useVerticalScroll({ + isScrolling, + goToIndex, + activeIndexRef, + }); + + const keyExtractor = useCallback( + (item: ImageObject, index: number) => + item && item.id ? item.id.toString() : index.toString(), + [] + ); + + const handlePressPreview = useCallback( + (item: ImageObject) => { + onPressImage?.(item); + }, + [onPressImage] + ); + + const handleZoomBegin = useCallback(() => { + // Optional: handle zoom begin in vertical mode + }, []); + + const renderItem = useCallback( + ({ item, index }: RenderImageProps) => { + return ( + + ); + }, + [activeIndex, resizeMode, renderCustomImage, handlePressPreview] + ); + + return ( + + {/* Floating header for vertical mode */} + {renderHeaderComponent ? ( + + {renderHeaderComponent(currentImage!, activeIndex)} + + ) : null} + + + + + + + + {renderFooterComponent ? ( + + {renderFooterComponent(currentImage!, activeIndex)} + + ) : null} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + content: { + flex: 1, + }, + floatingHeader: { + position: 'absolute', + top: 0, + width: '100%', + zIndex: 10, + }, + contentContainer: { + paddingTop: 60, + }, + footer: { + bottom: 0, + position: 'absolute', + width: '100%', + }, +}); + +export default VerticalFeed; diff --git a/src/vertical/index.ts b/src/vertical/index.ts new file mode 100644 index 0000000..1061df0 --- /dev/null +++ b/src/vertical/index.ts @@ -0,0 +1,3 @@ +export { default as VerticalFeed } from './VerticalFeed'; +export { useVerticalScroll } from './useVerticalScroll'; +export type { UseVerticalScrollProps, UseVerticalScrollReturn } from './useVerticalScroll'; diff --git a/src/vertical/useVerticalScroll.ts b/src/vertical/useVerticalScroll.ts new file mode 100644 index 0000000..af68d2f --- /dev/null +++ b/src/vertical/useVerticalScroll.ts @@ -0,0 +1,63 @@ +import { useCallback, useRef } from 'react'; +import { FlatList, ViewToken } from 'react-native'; +import { SharedValue, useAnimatedRef } from 'react-native-reanimated'; +import { ImageObject } from '../types'; + +const viewabilityConfig = { itemVisiblePercentThreshold: 60 }; + +export interface UseVerticalScrollProps { + isScrolling: SharedValue; + goToIndex: (index: number) => void; + activeIndexRef: React.MutableRefObject; +} + +export interface UseVerticalScrollReturn { + listRef: any; + viewabilityConfig: typeof viewabilityConfig; + onViewableItemsChanged: (info: { viewableItems: ViewToken[] }) => void; + handleManualScroll: () => void; + onScrollEnd: () => void; +} + +export function useVerticalScroll(props: UseVerticalScrollProps): UseVerticalScrollReturn { + const { + isScrolling, + goToIndex, + activeIndexRef, + } = props; + + const listRef = useAnimatedRef>(); + + const onViewableItemsChanged = useRef( + ({ viewableItems }: { viewableItems: ViewToken[] }) => { + if (!viewableItems.length) return; + + const minIndex = viewableItems + .map(v => v.index) + .filter((i): i is number => i != null) + .reduce((min, i) => Math.min(min, i), Number.POSITIVE_INFINITY); + + if (!Number.isFinite(minIndex)) return; + + if (minIndex !== activeIndexRef.current) { + goToIndex(minIndex); + } + } + ).current; + + const handleManualScroll = useCallback(() => { + isScrolling.value = true; + }, [isScrolling]); + + const onScrollEnd = useCallback(() => { + isScrolling.value = false; + }, [isScrolling]); + + return { + listRef, + viewabilityConfig, + onViewableItemsChanged, + handleManualScroll, + onScrollEnd, + }; +} From 0e863f47d36b69118c02ca4b7cf992d66c4301ba Mon Sep 17 00:00:00 2001 From: Damien Dulac Date: Thu, 19 Feb 2026 16:13:48 +0100 Subject: [PATCH 3/3] feat: add onEndReach to props --- package.json | 8 +-- src/ImageGallery.tsx | 78 ++++++++++++++-------------- src/horizontal/HorizontalGallery.tsx | 67 ++++++++++++------------ src/types.ts | 8 +-- src/vertical/VerticalFeed.tsx | 44 ++++++++-------- 5 files changed, 105 insertions(+), 100 deletions(-) diff --git a/package.json b/package.json index c015d24..66bd2ac 100644 --- a/package.json +++ b/package.json @@ -62,14 +62,14 @@ "eslint-plugin-react-hooks": "^4.3.0", "git-cz": "^4.9.0", "husky": "^7.0.4", - "prettier": "^2.4.1", + "prettier": "^3.8.1", "react": "~17.0.2", "react-native": "^0.64.2", "react-native-builder-bob": "^0.18.2", - "release-it": "^14.11.8", - "typescript": "^4.5.2", "react-native-gesture-handler": "~2.1.0", - "react-native-reanimated": "~2.3.1" + "react-native-reanimated": "~2.3.1", + "release-it": "^14.11.8", + "typescript": "^4.5.2" }, "peerDependencies": { "react": "*", diff --git a/src/ImageGallery.tsx b/src/ImageGallery.tsx index 76f7f4c..1eb3d59 100644 --- a/src/ImageGallery.tsx +++ b/src/ImageGallery.tsx @@ -1,43 +1,43 @@ import React from 'react'; -import { ImageGalleryProps, HorizontalGalleryProps, VerticalFeedProps } from './types'; -import { HorizontalGallery } from './horizontal'; -import { VerticalFeed } from './vertical'; +import {ImageGalleryProps, HorizontalGalleryProps, VerticalFeedProps} from './types'; +import {HorizontalGallery} from './horizontal'; +import {VerticalFeed} from './vertical'; + +const ImageGallery = ({ + mode, + horizontal, + // Legacy prop mapping + enableManualZoom, + onPressPreviewImage, + // Base props + images, + initialIndex, + resizeMode, + onPageChange, + renderCustomImage, + renderHeaderComponent, + renderFooterComponent, + onEndReached, + onEndReachedThreshold, + // Horizontal-specific props + hideThumbs, + thumbColor, + thumbSize, + thumbOffset, + thumbResizeMode, + renderCustomThumb, + disableSwipe, + autoScroll, + disableAutoScroll, + close, + // Vertical-specific props + ListHeaderComponent, + contentContainerStyle, + // New props + enableZoom, + onPressImage, + }: ImageGalleryProps) => { -const ImageGallery = (props: ImageGalleryProps) => { - const { - mode, - horizontal, - // Legacy prop mapping - enableManualZoom, - onPressPreviewImage, - // Base props - images, - initialIndex, - resizeMode, - onPageChange, - renderCustomImage, - renderHeaderComponent, - renderFooterComponent, - // Horizontal-specific props - hideThumbs, - thumbColor, - thumbSize, - thumbOffset, - thumbResizeMode, - renderCustomThumb, - disableSwipe, - autoScroll, - disableAutoScroll, - close, - // Vertical-specific props - onEndReached, - onEndReachedThreshold, - ListHeaderComponent, - contentContainerStyle, - // New props - enableZoom, - onPressImage, - } = props; // Determine effective mode: new 'mode' prop takes precedence over deprecated 'horizontal' const effectiveMode = mode ?? (horizontal === false ? 'vertical' : 'horizontal'); @@ -86,6 +86,8 @@ const ImageGallery = (props: ImageGalleryProps) => { autoScroll, disableAutoScroll, close, + onEndReached, + onEndReachedThreshold, }; return ; diff --git a/src/horizontal/HorizontalGallery.tsx b/src/horizontal/HorizontalGallery.tsx index 31c0087..8706871 100644 --- a/src/horizontal/HorizontalGallery.tsx +++ b/src/horizontal/HorizontalGallery.tsx @@ -1,30 +1,32 @@ -import React, { useEffect, useState, useCallback } from 'react'; -import { FlatList, StyleSheet, View } from 'react-native'; -import Animated, { useDerivedValue } from 'react-native-reanimated'; -import { HorizontalGalleryProps, ImageObject, RenderImageProps } from '../types'; -import { useGalleryState, ZoomContainer, ImagePreview, Thumbnail } from '../core'; -import { useHorizontalScroll } from './useHorizontalScroll'; - -const HorizontalGallery = (props: HorizontalGalleryProps) => { - const { - hideThumbs = false, - images, - initialIndex, - renderCustomImage, - renderCustomThumb, - renderFooterComponent, - renderHeaderComponent, - resizeMode = 'contain', - thumbColor = '#d9b44a', - thumbResizeMode = 'cover', - thumbSize = 48, - thumbOffset = 10, - onPressImage, - onPageChange, - autoScroll = 0, - disableAutoScroll = false, - enableZoom = false, - } = props; +import React, {useEffect, useState, useCallback} from 'react'; +import {FlatList, StyleSheet, View} from 'react-native'; +import Animated, {useDerivedValue} from 'react-native-reanimated'; +import {HorizontalGalleryProps, ImageObject, RenderImageProps} from '../types'; +import {useGalleryState, ZoomContainer, ImagePreview, Thumbnail} from '../core'; +import {useHorizontalScroll} from './useHorizontalScroll'; + +const HorizontalGallery = ({ + hideThumbs = false, + images, + initialIndex, + renderCustomImage, + renderCustomThumb, + renderFooterComponent, + renderHeaderComponent, + resizeMode = 'contain', + thumbColor = '#d9b44a', + thumbResizeMode = 'cover', + thumbSize = 48, + thumbOffset = 10, + onPressImage, + onPageChange, + autoScroll = 0, + disableAutoScroll = false, + enableZoom = false, + onEndReached, + onEndReachedThreshold + }: HorizontalGalleryProps) => { + const [autoScrollActive, setAutoScrollActive] = useState(autoScroll > 0); @@ -85,7 +87,7 @@ const HorizontalGallery = (props: HorizontalGalleryProps) => { }, []); const renderItem = useCallback( - ({ item, index }: RenderImageProps) => { + ({item, index}: RenderImageProps) => { return ( { ); const renderThumb = useCallback( - ({ item, index }: RenderImageProps) => { + ({item, index}: RenderImageProps) => { return ( { onScrollBeginDrag={handleManualScroll} onScrollEndDrag={onScrollEnd} getItemLayout={getImageLayout} - contentContainerStyle={{ alignItems: 'center' }} + contentContainerStyle={{alignItems: 'center'}} /> @@ -180,8 +182,9 @@ const HorizontalGallery = (props: HorizontalGalleryProps) => { ref={bottomRef} renderItem={renderThumb} showsHorizontalScrollIndicator={false} - ItemSeparatorComponent={() => } - onEndReachedThreshold={0.2} + ItemSeparatorComponent={() => } + onEndReached={onEndReached} + onEndReachedThreshold={onEndReachedThreshold} style={styles.bottomFlatlist} onScrollBeginDrag={handleManualScroll} /> diff --git a/src/types.ts b/src/types.ts index e213028..b94fcca 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,5 @@ import React from 'react'; -import { ImageResizeMode, StyleProp, ViewStyle } from 'react-native'; +import {ImageResizeMode, StyleProp, ViewStyle} from 'react-native'; // Gallery mode export type GalleryMode = 'horizontal' | 'vertical'; @@ -35,6 +35,9 @@ export interface BaseGalleryProps { onPageChange?: (index: number) => void; onPressImage?: (item: ImageObject) => void; + onEndReached?: () => void; + onEndReachedThreshold?: number; + renderCustomImage?: ( item: ImageObject, index: number, @@ -77,9 +80,6 @@ export interface HorizontalGalleryProps extends BaseGalleryProps { // Props specific to vertical feed mode export interface VerticalFeedProps extends BaseGalleryProps { - onEndReached?: () => void; - onEndReachedThreshold?: number; - ListHeaderComponent?: React.ReactNode; contentContainerStyle?: StyleProp; } diff --git a/src/vertical/VerticalFeed.tsx b/src/vertical/VerticalFeed.tsx index 919f866..a027673 100644 --- a/src/vertical/VerticalFeed.tsx +++ b/src/vertical/VerticalFeed.tsx @@ -1,26 +1,26 @@ -import React, { useCallback } from 'react'; -import { FlatList, StyleSheet, View } from 'react-native'; -import Animated, { useDerivedValue } from 'react-native-reanimated'; -import { VerticalFeedProps, ImageObject, RenderImageProps } from '../types'; -import { useGalleryState, ZoomContainer, ImagePreview } from '../core'; -import { useVerticalScroll } from './useVerticalScroll'; +import React, {useCallback} from 'react'; +import {StyleSheet, View} from 'react-native'; +import Animated, {useDerivedValue} from 'react-native-reanimated'; +import {VerticalFeedProps, ImageObject, RenderImageProps} from '../types'; +import {useGalleryState, ZoomContainer, ImagePreview} from '../core'; +import {useVerticalScroll} from './useVerticalScroll'; -const VerticalFeed = (props: VerticalFeedProps) => { - const { - images, - initialIndex, - renderCustomImage, - renderFooterComponent, - renderHeaderComponent, - resizeMode = 'contain', - onPressImage, - onPageChange, - onEndReached, - onEndReachedThreshold = 0.5, - enableZoom = false, - contentContainerStyle, - } = props; +const VerticalFeed = ({ + images, + initialIndex, + renderCustomImage, + renderFooterComponent, + renderHeaderComponent, + resizeMode = 'contain', + onPressImage, + onPageChange, + onEndReached, + onEndReachedThreshold = 0.5, + enableZoom = false, + contentContainerStyle, + }: VerticalFeedProps) => { + const { activeIndex, @@ -69,7 +69,7 @@ const VerticalFeed = (props: VerticalFeedProps) => { }, []); const renderItem = useCallback( - ({ item, index }: RenderImageProps) => { + ({item, index}: RenderImageProps) => { return (