import React, { useEffect, useRef, useState } from 'react'; import { Dimensions, Image, ImageStyle, Keyboard, Platform, StatusBar, StyleSheet, ViewStyle, } from 'react-native'; import BottomSheet from '@gorhom/bottom-sheet'; import { PanGestureHandler, PanGestureHandlerGestureEvent, PinchGestureHandler, PinchGestureHandlerGestureEvent, TapGestureHandler, TapGestureHandlerGestureEvent, } from 'react-native-gesture-handler'; import Animated, { cancelAnimation, Easing, interpolate, runOnJS, runOnUI, useAnimatedGestureHandler, useAnimatedStyle, useDerivedValue, useSharedValue, withDecay, withTiming, } from 'react-native-reanimated'; import { AnimatedGalleryImage } from './components/AnimatedGalleryImage'; import { ImageGalleryFooter, ImageGalleryFooterCustomComponentProps, } from './components/ImageGalleryFooter'; import { ImageGalleryHeader, ImageGalleryHeaderCustomComponentProps, } from './components/ImageGalleryHeader'; import { ImageGalleryOverlay } from './components/ImageGalleryOverlay'; import { ImageGalleryGridImageComponents, ImageGrid } from './components/ImageGrid'; import { ImageGalleryGridHandleCustomComponentProps, ImageGridHandle, } from './components/ImageGridHandle'; import { useImageGalleryContext } from '../../contexts/imageGalleryContext/ImageGalleryContext'; import { useOverlayContext } from '../../contexts/overlayContext/OverlayContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { triggerHaptic } from '../../native'; import { vh, vw } from '../../utils/utils'; import type { UserResponse } from 'stream-chat'; import type { DefaultAttachmentType, DefaultChannelType, DefaultCommandType, DefaultEventType, DefaultMessageType, DefaultReactionType, DefaultUserType, UnknownType, } from '../../types/types'; const isAndroid = Platform.OS === 'android'; const fullScreenHeight = Dimensions.get('screen').height; const measuredScreenHeight = vh(100); const screenWidth = vw(100); const halfScreenWidth = vw(50); const MARGIN = 32; export enum HasPinched { FALSE = 0, TRUE, } export enum IsSwiping { UNDETERMINED = 0, TRUE, FALSE, } export type ImageGalleryCustomComponents = { /** * Override props for following UI components, which are part of [image gallery](https://github.com/GetStream/stream-chat-react-native/wiki/Cookbook-v3.0#gallery-components). * * - [ImageGalleryFooter](#ImageGalleryFooter) * * - [ImageGrid](#ImageGrid) * * - [ImageGridHandle](#ImageGridHandle) * * - [ImageGalleryHeader](#ImageGalleryHeader) * * e.g., * * ```js * { * footer: { * ShareIcon: CustomShareIconComponent * }, * grid: { * avatarComponent: CustomAvatarComponent * }, * gridHandle: { * centerComponent: CustomCenterComponent * }, * header: { * CloseIcon: CustomCloseButtonComponent * }, * } * ``` * @overrideType object */ imageGalleryCustomComponents?: { footer?: ImageGalleryFooterCustomComponentProps; grid?: ImageGalleryGridImageComponents; gridHandle?: ImageGalleryGridHandleCustomComponentProps; header?: ImageGalleryHeaderCustomComponentProps; }; }; type Props = ImageGalleryCustomComponents & { overlayOpacity: Animated.SharedValue; visible: boolean; imageGalleryGridHandleHeight?: number; /** * This should be */ imageGalleryGridSnapPoints?: [string | number, string | number]; numberOfImageGalleryGridColumns?: number; }; export const ImageGallery = < At extends UnknownType = DefaultAttachmentType, Ch extends UnknownType = DefaultChannelType, Co extends string = DefaultCommandType, Ev extends UnknownType = DefaultEventType, Me extends UnknownType = DefaultMessageType, Re extends UnknownType = DefaultReactionType, Us extends UnknownType = DefaultUserType, >( props: Props, ) => { const { imageGalleryCustomComponents, imageGalleryGridHandleHeight, imageGalleryGridSnapPoints, numberOfImageGalleryGridColumns, overlayOpacity, visible, } = props; const { theme: { colors: { white_snow }, imageGallery: { backgroundColor }, }, } = useTheme(); const { overlay, setBlurType, setOverlay, translucentStatusBar } = useOverlayContext(); const { image, images, setImage } = useImageGalleryContext(); /** * Height constants */ const statusBarHeight = StatusBar.currentHeight ?? 0; const bottomBarHeight = fullScreenHeight - measuredScreenHeight - statusBarHeight; const androidScreenHeightAdjustment = translucentStatusBar ? bottomBarHeight === statusBarHeight || bottomBarHeight < 0 ? 0 : statusBarHeight : bottomBarHeight === statusBarHeight || bottomBarHeight < 0 ? -statusBarHeight : 0; const screenHeight = isAndroid ? Dimensions.get('window').height + androidScreenHeightAdjustment : vh(100); const halfScreenHeight = screenHeight / 2; const quarterScreenHeight = screenHeight / 4; const snapPoints = React.useMemo(() => [0, (screenHeight * 9) / 10], []); /** * BottomSheet ref */ const bottomSheetRef = useRef(null); /** * BottomSheet state */ const [currentBottomSheetIndex, setCurrentBottomSheetIndex] = useState(0); const animatedBottomSheetIndex = useSharedValue(0); /** * Fade animation for screen, it is always rendered with pointerEvents * set to none for fast opening */ const showScreen = useSharedValue(0); const fadeScreen = (show: boolean) => { 'worklet'; showScreen.value = withTiming(show ? 1 : 0, { duration: 250, easing: Easing.out(Easing.ease), }); }; /** * Run the fade animation on visible change */ useEffect(() => { if (visible) { Keyboard.dismiss(); } fadeScreen(visible); }, [visible]); /** * Image height from URL or default to full screen height */ const [currentImageHeight, setCurrentImageHeight] = useState(screenHeight); /** * JS and UI index values, the JS follows the UI but is needed * for rendering the virtualized image list */ const [selectedIndex, setSelectedIndex] = useState(0); const index = useSharedValue(0); /** * Header visible value for animating in out */ const headerFooterVisible = useSharedValue(1); /** * Values to track scale for haptic feedback firing */ const hasHitBottomScale = useSharedValue(1); const hasHitTopScale = useSharedValue(0); /** * Gesture handler refs */ const doubleTapRef = useRef(null); const panRef = useRef(null); const pinchRef = useRef(null); const singleTapRef = useRef(null); /** * Shared values for movement */ const offsetX = useSharedValue(0); const offsetY = useSharedValue(0); const translateX = useSharedValue(0); const translateY = useSharedValue(0); const offsetScale = useSharedValue(1); const scale = useSharedValue(1); const translationX = useSharedValue(0); /** * Shared values for touch tracking */ const originX = useSharedValue(0); const originY = useSharedValue(0); const focalX = useSharedValue(0); const focalY = useSharedValue(0); const oldFocalX = useSharedValue(0); const oldFocalY = useSharedValue(0); const focalOffsetX = useSharedValue(0); const focalOffsetY = useSharedValue(0); const adjustedFocalX = useSharedValue(0); const adjustedFocalY = useSharedValue(0); const tapX = useSharedValue(0); const tapY = useSharedValue(0); /** * Shared values for gesture tracking */ const numberOfPinchFingers = useSharedValue(0); const isSwiping = useSharedValue(0); const isPinch = useSharedValue(false); const hasPinched = useSharedValue(0); /** * Reset gesture values for use on touch release */ const resetTouchValues = () => { 'worklet'; focalX.value = 0; focalY.value = 0; oldFocalX.value = 0; oldFocalY.value = 0; originX.value = 0; originY.value = 0; focalOffsetX.value = 0; focalOffsetY.value = 0; numberOfPinchFingers.value = 0; isPinch.value = false; isSwiping.value = IsSwiping.UNDETERMINED; }; /** * Reset movement values for use on selected photo change */ const resetMovementValues = () => { 'worklet'; translateX.value = 0; translateY.value = 0; scale.value = 1; offsetScale.value = 1; }; /** * Reset all key values for visible */ const resetVisibleValues = () => { 'worklet'; resetTouchValues(); resetMovementValues(); headerFooterVisible.value = 1; offsetX.value = 0; offsetY.value = 0; adjustedFocalX.value = 0; adjustedFocalY.value = 0; tapX.value = 0; tapY.value = 0; }; useEffect(() => { if (!visible) { resetVisibleValues(); } }, [visible]); /** * Photos array created from all currently available * photo attachments */ const photos = images.reduce((acc: Photo[], cur) => { const attachmentImages = cur.attachments?.filter( (attachment) => attachment.type === 'image' && !attachment.title_link && !attachment.og_scrape_url && (attachment.image_url || attachment.thumb_url), ) || []; const attachmentPhotos = attachmentImages.map((attachmentImage) => ({ created_at: cur.created_at, id: `photoId-${cur.id}-${attachmentImage.image_url || attachmentImage.thumb_url}`, messageId: cur.id, uri: attachmentImage.image_url || attachmentImage.thumb_url || '', user: cur.user, user_id: cur.user_id, })); return [...acc, ...attachmentPhotos]; }, []); /** * Photos length needs to be kept as a const here so if the length * changes it causes the pan gesture handler function to refresh. This * does not work if the calculation for the length of the array is left * inside the gesture handler as it will have an array as a dependency */ const photoLength = photos.length; /** * Set selected photo when changed via pressing in the message list */ useEffect(() => { const updatePosition = (newIndex: number) => { 'worklet'; if (newIndex > -1) { index.value = newIndex; translationX.value = -(screenWidth + MARGIN) * newIndex; runOnJS(setSelectedIndex)(newIndex); } }; const newIndex = photos.findIndex( (photo) => photo.messageId === image?.messageId && photo.uri === image?.url, ); runOnUI(updatePosition)(newIndex); }, [image, photoLength]); /** * Image heights are not provided and therefore need to be calculated. * We start by allowing the image to be the full height then reduce it * to the proper scaled height based on the width being restricted to the * screen width when the dimensions are received. */ const uriForCurrentImage = photos[selectedIndex]?.uri; useEffect(() => { setCurrentImageHeight(screenHeight); if (photos[index.value]?.uri) { Image.getSize(photos[index.value].uri, (width, height) => { const imageHeight = Math.floor(height * (screenWidth / width)); setCurrentImageHeight(imageHeight > screenHeight ? screenHeight : imageHeight); }); } }, [uriForCurrentImage]); /** * We use simultaneousHandlers to allow pan and pinch gesture handlers * depending on the gesture. The touch is fully handled by the pinch * gesture handler once it has began so all interactions by the pan * handler are blocked if isPinch.value is true */ const onPan = useAnimatedGestureHandler( { onActive: (evt) => { if (evt.numberOfPointers === 1 && !isPinch.value) { /** * If Android where a second finger cannot be added back and * removing one finger returns to Pan Handler then adjust origin * on finger remove and set swiping false */ if (isAndroid && hasPinched.value === HasPinched.TRUE) { hasPinched.value = HasPinched.FALSE; isSwiping.value = IsSwiping.FALSE; offsetX.value = translateX.value + evt.translationX; offsetY.value = translateY.value - evt.translationY; } /** * isSwiping is used to prevent Y movement if a clear swipe to next * or previous is begun when at the edge of a photo. The value is * either 0, 1, or 2, via the IsSwiping enum designating undetermined, * true, or false and is reset on releasing the touch */ if (isSwiping.value === IsSwiping.UNDETERMINED) { const maxXYRatio = isAndroid ? 1 : 0.25; if ( Math.abs(evt.translationX / evt.translationY) > maxXYRatio && (Math.abs(-halfScreenWidth * (scale.value - 1) - offsetX.value) < 3 || Math.abs(halfScreenWidth * (scale.value - 1) - offsetX.value) < 3) ) { isSwiping.value = IsSwiping.TRUE; } if (Math.abs(evt.translationY) > 25) { isSwiping.value = IsSwiping.FALSE; } } /** * localEvtScale is used for swipe away */ const localEvtScale = scale.value / offsetScale.value; /** * If not swiping translate the image in X and Y to the event * translation plus offset. If swiping only translate X, if * swiping down when at top of screen or centered balance scale * using offset to a degree (this needs improvement the calculation * is incorrect but likely needs origin use to be 100%) */ translateX.value = scale.value !== offsetScale.value ? offsetX.value * localEvtScale - evt.translationX : offsetX.value - evt.translationX; translateY.value = isSwiping.value !== IsSwiping.TRUE ? scale.value !== offsetScale.value ? offsetY.value * localEvtScale + evt.translationY : offsetY.value + evt.translationY : translateY.value; /** * If swiping down start scaling down the image for swipe * away effect */ scale.value = currentImageHeight * offsetScale.value < screenHeight && translateY.value > 0 ? offsetScale.value * (1 - (1 / 3) * (translateY.value / screenHeight)) : currentImageHeight * offsetScale.value > screenHeight && translateY.value > (currentImageHeight / 2) * offsetScale.value - halfScreenHeight ? offsetScale.value * (1 - (1 / 3) * ((translateY.value - ((currentImageHeight / 2) * offsetScale.value - halfScreenHeight)) / screenHeight)) : scale.value; overlayOpacity.value = localEvtScale; } }, onFinish: (evt) => { if (!isPinch.value && evt.numberOfPointers < 2) { /** * To determine if the fling should page to the next image we * calculate a final position based on the current position and * event velocity */ const finalXPosition = evt.translationX - evt.velocityX * 0.3; const finalYPosition = evt.translationY + evt.velocityY * 0.1; /** * If there is a next photo, the image is lined up to the right * edge, the swipe is to the left, and the final position is more * than half the screen width, move to the next image */ if ( index.value < photoLength - 1 && Math.abs(halfScreenWidth * (scale.value - 1) + offsetX.value) < 3 && translateX.value < 0 && finalXPosition < -halfScreenWidth && isSwiping.value === IsSwiping.TRUE ) { cancelAnimation(translationX); translationX.value = withTiming( -(screenWidth + MARGIN) * (index.value + 1), { duration: 200, easing: Easing.out(Easing.ease), }, () => { resetMovementValues(); index.value = index.value + 1; runOnJS(setSelectedIndex)(index.value); }, ); /** * If there is a previous photo, the image is lined up to the left * edge, the swipe is to the right, and the final position is more * than half the screen width, move to the previous image */ } else if ( index.value > 0 && Math.abs(-halfScreenWidth * (scale.value - 1) + offsetX.value) < 3 && translateX.value > 0 && finalXPosition > halfScreenWidth && isSwiping.value === IsSwiping.TRUE ) { cancelAnimation(translationX); translationX.value = withTiming( -(screenWidth + MARGIN) * (index.value - 1), { duration: 200, easing: Easing.out(Easing.ease), }, () => { resetMovementValues(); index.value = index.value - 1; runOnJS(setSelectedIndex)(index.value); }, ); } /** * When the pan is finished if the scale is less than 1 return the * photo to center, if the photo is inside the edges of the screen * return the photo to line up with the edges, otherwise use decay * with a clamping at the edges to give the effect the image is * sliding along using velocity and friction */ translateX.value = scale.value < 1 ? withTiming(0) : translateX.value > halfScreenWidth * (scale.value - 1) ? withTiming(halfScreenWidth * (scale.value - 1), { duration: 200, }) : translateX.value < -halfScreenWidth * (scale.value - 1) ? withTiming(-halfScreenWidth * (scale.value - 1), { duration: 200, }) : withDecay({ clamp: [ -halfScreenWidth * (scale.value - 1), halfScreenWidth * (scale.value - 1), ], deceleration: 0.99, velocity: -evt.velocityX, }); /** * When the pan is finished if the height is less than the screen * height return the photo to center, if the photo is inside the * edges of the screen return the photo to line up with the edges, * otherwise use decay with a clamping at the edges to give the effect * the image is sliding along using velocity and friction */ translateY.value = currentImageHeight * scale.value < screenHeight ? withTiming(0) : translateY.value > (currentImageHeight / 2) * scale.value - halfScreenHeight ? withTiming((currentImageHeight / 2) * scale.value - halfScreenHeight) : translateY.value < (-currentImageHeight / 2) * scale.value + halfScreenHeight ? withTiming((-currentImageHeight / 2) * scale.value + halfScreenHeight) : withDecay({ clamp: [ (-currentImageHeight / 2) * scale.value + halfScreenHeight, (currentImageHeight / 2) * scale.value - halfScreenHeight, ], deceleration: 0.99, velocity: evt.velocityY, }); resetTouchValues(); /** * If the scale has been reduced below one, i.e. zoomed out, translate * the zoom back to one */ scale.value = scale.value !== offsetScale.value ? withTiming(offsetScale.value) : offsetScale.value; /** * If the photo is centered or at the top of the screen if scaled larger * than the screen, and not paging left or right, and the final Y position * is greater than half the screen using swipe velocity and position, close * the overlay */ if ( finalYPosition > halfScreenHeight && offsetY.value + 8 >= (currentImageHeight / 2) * scale.value - halfScreenHeight && isSwiping.value !== IsSwiping.TRUE && translateY.value !== 0 && !( Math.abs(halfScreenWidth * (scale.value - 1) + offsetX.value) < 3 && translateX.value < 0 && finalXPosition < -halfScreenWidth ) && !( Math.abs(-halfScreenWidth * (scale.value - 1) + offsetX.value) < 3 && translateX.value > 0 && finalXPosition > halfScreenWidth ) ) { cancelAnimation(translateX); cancelAnimation(translateY); cancelAnimation(scale); overlayOpacity.value = withTiming( 0, { duration: 200, easing: Easing.out(Easing.ease), }, () => { showScreen.value = 0; runOnJS(setOverlay)('none'); runOnJS(setBlurType)(undefined); }, ); scale.value = withTiming(0.6, { duration: 200, easing: Easing.out(Easing.ease), }); translateY.value = evt.velocityY > 1000 ? withDecay({ velocity: evt.velocityY, }) : withTiming(halfScreenHeight + (currentImageHeight / 2) * scale.value, { duration: 200, easing: Easing.out(Easing.ease), }); translateX.value = withDecay({ velocity: -evt.velocityX, }); } } }, onStart: () => { if (!isPinch.value) { /** * Cancel any previous motion animation on translations when a touch * begins to interrupt the animation and take over the position handling */ cancelAnimation(translateX); cancelAnimation(translateY); cancelAnimation(scale); offsetX.value = translateX.value; offsetY.value = translateY.value; } /** * Reset hasPinched for Android single finger offset */ hasPinched.value = HasPinched.FALSE; }, }, [currentImageHeight, photoLength], ); /** * On pinch is run when two or more fingers touch the screen, it then takes over * all touch handling even if the number of fingers is reduced to one until the * touch is complete */ const onPinch = useAnimatedGestureHandler( { onActive: (evt) => { /** * Android starts with a zero event with 1 touch instead of two * we therefore must wait to capture starting info until the double * touch begins */ if (!isPinch.value && isAndroid) { /** * Set hasPinched to true so when removing one finger the pan active * state adjusts the offset */ hasPinched.value = HasPinched.TRUE; /** * Cancel any previous motion animation on translations when a touch * begins to interrupt the animation and take over the position handling */ cancelAnimation(translateX); cancelAnimation(translateY); cancelAnimation(scale); /** * Reset isSwiping as now the pan gesture handler is no longer running */ isSwiping.value = IsSwiping.UNDETERMINED; /** * Set initial values for pinch gesture interaction handler */ numberOfPinchFingers.value = evt.numberOfPointers; offsetX.value = translateX.value; offsetY.value = translateY.value; adjustedFocalX.value = evt.focalX - (halfScreenWidth - offsetX.value); adjustedFocalY.value = evt.focalY - (halfScreenHeight + offsetY.value); originX.value = adjustedFocalX.value; originY.value = adjustedFocalY.value; offsetScale.value = scale.value; } /** * Set pinch to true to stop all pan gesture interactions, we do this * again here for Android outside the check that creates type */ isPinch.value = true; /** * The scale is clamped to a minimum of 1 and maximum of 8 for aesthetics. * We use the clamped value to determine a local event scale so the focal * point does not become out of sync with the actual photo scaling, e.g. * evt.scale is 20 but scale is 8, using evt.scale for offset will put the * photo and calculations out of sync */ scale.value = clamp(offsetScale.value * evt.scale, 1, 8); const localEvtScale = scale.value / offsetScale.value; /** * When we hit the top or bottom of the scale clamping we run a haptic * trigger, we track if it has been run to not spam the trigger */ if (scale.value !== 8 && scale.value !== 1) { hasHitTopScale.value = 0; hasHitBottomScale.value = 0; } else if (scale.value === 8 && hasHitTopScale.value === 0) { hasHitTopScale.value = 1; runOnJS(triggerHaptic)('impactLight'); } else if (scale.value === 1 && hasHitBottomScale.value === 0) { hasHitBottomScale.value = 1; runOnJS(triggerHaptic)('impactLight'); } /** * We calculate the adjusted focal point on the photo using the events * focal position on the screen, screen size, and current photo offset */ adjustedFocalX.value = evt.focalX - (halfScreenWidth - offsetX.value); adjustedFocalY.value = evt.focalY - (halfScreenHeight + offsetY.value); /** * If the number of fingers on the screen changes, the position of the * focal point will change and this needs to be accounted for, e.g. if * two fingers are on the screen the focal is between them, but if one is * then removed the focal is now at the remaining fingers touch position. * If this happens without accounting for the change the image will jump * around, we keep track of the previous two finger focal to adjust for this * change in a reduction from two fingers to one, then if another finger * is added again we adjust the origin to account for the difference between * the original two finger touch and the new two finger touch position. */ if (numberOfPinchFingers.value !== evt.numberOfPointers) { numberOfPinchFingers.value = evt.numberOfPointers; if (evt.numberOfPointers === 1) { focalOffsetX.value = oldFocalX.value - adjustedFocalX.value; focalOffsetY.value = oldFocalY.value - adjustedFocalY.value; } else if (numberOfPinchFingers.value > 1) { originX.value = originX.value - (oldFocalX.value / localEvtScale - adjustedFocalX.value / localEvtScale); originY.value = originY.value - (oldFocalY.value / localEvtScale - adjustedFocalY.value / localEvtScale); } } /** * If pinch handler has been activated via two fingers then the fingers * reduced to one we keep track of the old focal using the focal offset * from when the number of fingers was two. We then translate the photo * taking into account the offset, focal, focal offset, origin, and scale. */ if (numberOfPinchFingers.value === 1) { oldFocalX.value = adjustedFocalX.value + focalOffsetX.value; oldFocalY.value = adjustedFocalY.value + focalOffsetY.value; translateX.value = offsetX.value - oldFocalX.value + localEvtScale * originX.value; translateY.value = offsetY.value + oldFocalY.value - localEvtScale * originY.value; /** * If the number of fingers in the gesture is greater than one the * adjusted focal point is saved as the old focal and the photo is * translated taking into account the offset, focal, origin, and scale. */ } else if (numberOfPinchFingers.value > 1) { oldFocalX.value = adjustedFocalX.value; oldFocalY.value = adjustedFocalY.value; translateX.value = offsetX.value - adjustedFocalX.value + localEvtScale * originX.value; translateY.value = offsetY.value + adjustedFocalY.value - localEvtScale * originY.value; } }, onFinish: () => { if (isPinch.value) { /** * When the pinch is finished if the scale is less than 1 return the * photo to center, if the photo is inside the edges of the screen * return the photo to line up with the edges, otherwise leave the * photo in its current position */ translateX.value = scale.value < 1 ? withTiming(0) : translateX.value > halfScreenWidth * (scale.value - 1) ? withTiming(halfScreenWidth * (scale.value - 1)) : translateX.value < -halfScreenWidth * (scale.value - 1) ? withTiming(-halfScreenWidth * (scale.value - 1)) : translateX.value; /** * When the pinch is finished if the height is less than the screen * height return the photo to center, if the photo is inside the * edges of the screen return the photo to line up with the edges, * otherwise leave the photo in its current position */ translateY.value = currentImageHeight * scale.value < screenHeight ? withTiming(0) : translateY.value > (currentImageHeight / 2) * scale.value - screenHeight / 2 ? withTiming((currentImageHeight / 2) * scale.value - screenHeight / 2) : translateY.value < (-currentImageHeight / 2) * scale.value + screenHeight / 2 ? withTiming((-currentImageHeight / 2) * scale.value + screenHeight / 2) : translateY.value; /** * If the scale has been reduced below one, i.e. zoomed out, translate * the zoom back to one */ offsetScale.value = scale.value < 1 ? 1 : scale.value; scale.value = scale.value < 1 ? withTiming(1) : scale.value; resetTouchValues(); } }, onStart: (evt) => { /** * Android starts with a zero event with 1 touch instead of two * we therefore must wait to capture starting info until the double * touch begins */ if (!isAndroid) { /** * Cancel any previous motion animation on translations when a touch * begins to interrupt the animation and take over the position handling */ cancelAnimation(translateX); cancelAnimation(translateY); cancelAnimation(scale); /** * Set pinch to true to stop all pan gesture interactions */ isPinch.value = true; /** * Reset isSwiping as now the pan gesture handler is no longer running */ isSwiping.value = IsSwiping.UNDETERMINED; /** * Set initial values for pinch gesture interaction handler */ numberOfPinchFingers.value = evt.numberOfPointers; offsetX.value = translateX.value; offsetY.value = translateY.value; adjustedFocalX.value = evt.focalX - (halfScreenWidth - offsetX.value); adjustedFocalY.value = evt.focalY - (halfScreenHeight + offsetY.value); originX.value = adjustedFocalX.value; originY.value = adjustedFocalY.value; offsetScale.value = scale.value; } /** * Reset hasPinched for Android single finger offset */ hasPinched.value = HasPinched.FALSE; }, }, [currentImageHeight], ); /** * Single tap handler for header hiding and showing */ const onSingleTap = useAnimatedGestureHandler({ onActive: () => { cancelAnimation(headerFooterVisible); headerFooterVisible.value = headerFooterVisible.value > 0 ? withTiming(0) : withTiming(1); }, }); /** * Double tap handler to zoom back out and hide header and footer */ const onDoubleTap = useAnimatedGestureHandler({ onActive: (evt) => { if (Math.abs(tapX.value - evt.absoluteX) < 64 && Math.abs(tapY.value - evt.absoluteY) < 64) { if (offsetScale.value === 1 && offsetX.value === 0 && offsetY.value === 0) { offsetScale.value = 2; scale.value = withTiming(2, { duration: 200, easing: Easing.out(Easing.ease), }); translateX.value = withTiming(evt.absoluteX - halfScreenWidth, { duration: 200, easing: Easing.out(Easing.ease), }); if (currentImageHeight * 2 > screenHeight) { const translateYTopBottom = evt.absoluteY > halfScreenHeight ? -(currentImageHeight * 2 - screenHeight) / 2 : (currentImageHeight * 2 - screenHeight) / 2; translateY.value = withTiming(translateYTopBottom, { duration: 200, easing: Easing.out(Easing.ease), }); } } else { offsetScale.value = 1; scale.value = withTiming(1, { duration: 200, easing: Easing.out(Easing.ease), }); offsetX.value = 0; offsetY.value = 0; translateX.value = withTiming(0, { duration: 200, easing: Easing.out(Easing.ease), }); translateY.value = withTiming(0, { duration: 200, easing: Easing.out(Easing.ease), }); if (headerFooterVisible.value !== 0) { cancelAnimation(headerFooterVisible); headerFooterVisible.value = withTiming(0); } } } }, onStart: (evt) => { tapX.value = evt.absoluteX; tapY.value = evt.absoluteY; }, }); /** * If the header is visible we scale down the opacity of it as the * image is swiped downward */ const headerFooterOpacity = useDerivedValue( () => currentImageHeight * scale.value < screenHeight && translateY.value > 0 ? 1 - translateY.value / quarterScreenHeight : currentImageHeight * scale.value > screenHeight && translateY.value > (currentImageHeight / 2) * scale.value - halfScreenHeight ? 1 - (translateY.value - ((currentImageHeight / 2) * scale.value - halfScreenHeight)) / quarterScreenHeight : 1, [currentImageHeight], ); /** * This transition and scaleX reverse lets use scroll left */ const pagerStyle = useAnimatedStyle( () => ({ transform: [ { scaleX: -1 }, { translateX: translationX.value, }, ], }), [visible], ); /** * Simple background color animation on Y movement */ const containerBackground = useAnimatedStyle( () => ({ backgroundColor: backgroundColor || white_snow, opacity: headerFooterOpacity.value, }), [headerFooterOpacity], ); /** * Show screen style as component is always rendered we hide it * down and up and set opacity to 0 for good measure */ const showScreenStyle = useAnimatedStyle( () => ({ opacity: interpolate(showScreen.value, [0, 0.01, 1], [0, 1, 1]), transform: [ { translateY: interpolate(showScreen.value, [0, 1], [screenHeight, 0]), }, ], }), [], ); /** * Functions to open and close BottomSheet with image grid */ const closeGridView = () => { if (bottomSheetRef.current) { bottomSheetRef.current.close(); } }; const openGridView = () => { if (bottomSheetRef.current) { bottomSheetRef.current.snapTo(1); } }; return ( {photos.map((photo, i) => ( i} scale={scale} screenHeight={screenHeight} selected={selectedIndex === i} shouldRender={Math.abs(selectedIndex - i) < 4} style={{ height: screenHeight * 8, marginRight: MARGIN, width: screenWidth * 8, }} translateX={translateX} translateY={translateY} /> ))} opacity={headerFooterOpacity} photo={photos[selectedIndex]} visible={headerFooterVisible} {...imageGalleryCustomComponents?.header} /> opacity={headerFooterOpacity} openGridView={openGridView} photo={photos[selectedIndex]} photoLength={photoLength} selectedIndex={selectedIndex} visible={headerFooterVisible} {...imageGalleryCustomComponents?.footer} /> ( )} handleHeight={imageGalleryGridHandleHeight ?? 40} index={0} onChange={(index: number) => setCurrentBottomSheetIndex(index)} ref={bottomSheetRef} snapPoints={imageGalleryGridSnapPoints || snapPoints} > ); }; /** * Clamping worklet to clamp the scaling */ export const clamp = (value: number, lowerBound: number, upperBound: number) => { 'worklet'; return Math.min(Math.max(lowerBound, value), upperBound); }; const styles = StyleSheet.create({ animatedContainer: { alignItems: 'center', flexDirection: 'row', }, }); export type Photo = { id: string; uri: string; created_at?: string | Date; messageId?: string; user?: UserResponse | null; user_id?: string; }; ImageGallery.displayName = 'ImageGallery{imageGallery}';