diff --git a/package.json b/package.json
index 67a041d..66bd2ac 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@fidme/react-native-image-gallery",
- "version": "1.7.2",
+ "version": "2.0.0-beta.1",
"access": "public",
"description": "React Native Image Gallery with Thumbnails",
"main": "lib/commonjs/index.js",
@@ -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 2003968..1eb3d59 100644
--- a/src/ImageGallery.tsx
+++ b/src/ImageGallery.tsx
@@ -1,293 +1,96 @@
-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 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) => {
+
+
+ // 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,
+ };
-const { width: deviceWidth } = Dimensions.get('window');
+ return ;
+ }
-const ImageGallery = (props: IProps) => {
- const {
- hideThumbs = false,
+ const horizontalProps: HorizontalGalleryProps = {
images,
initialIndex,
+ resizeMode,
+ enableZoom: effectiveEnableZoom,
+ onPageChange,
+ onPressImage: effectiveOnPressImage,
renderCustomImage,
- renderCustomThumb,
- renderFooterComponent,
renderHeaderComponent,
- resizeMode = "contain",
- thumbColor = "#d9b44a",
- thumbResizeMode = "cover",
- thumbSize = 48,
- thumbOffset= 10,
+ renderFooterComponent,
+ hideThumbs,
+ thumbColor,
+ thumbSize,
+ thumbOffset,
+ thumbResizeMode,
+ renderCustomThumb,
+ disableSwipe,
+ autoScroll,
+ disableAutoScroll,
+ close,
onEndReached,
- onPressPreviewImage,
- onPageChange,
- autoScroll = 0,
- disableAutoScroll = false,
- enableManualZoom = false,
- } = 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 (
-
- );
+ onEndReachedThreshold,
};
- 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);
- };
- }, [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]
- );
-
- const handleManualScroll = () => {
- isScrolling.value = true;
- setAutoScrollActive(false);
- };
-
- const onScrollEnd = () => {
- isScrolling.value = false;
- };
-
- 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 067dbe8..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,13 +469,13 @@ export default function Zoom(
style={[contentContainerAnimatedStyle, contentContainerStyle]}
onLayout={onLayoutContent}
>
- {React.cloneElement(children, {
+ {React.cloneElement(children as React.ReactElement, {
animatedProps: childrenAnimatedProps,
})}
-
+
{
+ 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..8706871
--- /dev/null
+++ b/src/horizontal/HorizontalGallery.tsx
@@ -0,0 +1,226 @@
+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);
+
+ 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 : (
+
+ }
+ onEndReached={onEndReached}
+ onEndReachedThreshold={onEndReachedThreshold}
+ 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..b94fcca 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,29 +1,42 @@
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;
+ onPressImage?: (item: ImageObject) => void;
- renderCustomThumb?: (
- item: ImageObject,
- index: number,
- isSelected: boolean
- ) => React.ReactNode;
+ onEndReached?: () => void;
+ onEndReachedThreshold?: number;
renderCustomImage?: (
item: ImageObject,
@@ -42,29 +55,84 @@ 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 {
+ 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..a027673
--- /dev/null
+++ b/src/vertical/VerticalFeed.tsx
@@ -0,0 +1,152 @@
+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 = ({
+ images,
+ initialIndex,
+ renderCustomImage,
+ renderFooterComponent,
+ renderHeaderComponent,
+ resizeMode = 'contain',
+ onPressImage,
+ onPageChange,
+ onEndReached,
+ onEndReachedThreshold = 0.5,
+ enableZoom = false,
+ contentContainerStyle,
+ }: VerticalFeedProps) => {
+
+
+ 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,
+ };
+}