[WIP] Rewrite Android lightbox

This commit is contained in:
Dan Abramov
2023-09-28 18:04:56 +01:00
parent fd5bbb2769
commit 44733bb390
3 changed files with 219 additions and 567 deletions
@@ -1,16 +1,7 @@
/**
* Copyright (c) JOB TODAY S.A. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import React, {useCallback, useRef, useState} from 'react'
import React, {useCallback, useRef, useEffect, useState} from 'react'
import {
Animated,
ScrollView,
View,
Dimensions,
StyleSheet,
NativeScrollEvent,
@@ -18,19 +9,24 @@ import {
NativeMethodsMixin,
} from 'react-native'
import {Image} from 'expo-image'
import Animated, {
useAnimatedStyle,
useDerivedValue,
useSharedValue,
} from 'react-native-reanimated'
import {
GestureDetector,
Gesture
} from 'react-native-gesture-handler'
const AnimatedImage = Animated.createAnimatedComponent(Image)
import useImageDimensions from '../../hooks/useImageDimensions'
import usePanResponder from '../../hooks/usePanResponder'
import {getImageStyles, getImageTransform} from '../../utils'
import {ImageSource} from '../../@types'
import {ImageLoading} from './ImageLoading'
const SWIPE_CLOSE_OFFSET = 75
const SWIPE_CLOSE_VELOCITY = 1.75
const SCREEN = Dimensions.get('window')
const SCREEN_WIDTH = SCREEN.width
const SCREEN_HEIGHT = SCREEN.height
type Props = {
imageSrc: ImageSource
@@ -42,7 +38,112 @@ type Props = {
doubleTapToZoomEnabled?: boolean
}
const AnimatedImage = Animated.createAnimatedComponent(Image)
type Matrix3 = readonly [
number,
number,
number,
number,
number,
number,
number,
number,
number
];
function createTransform(): Matrix3 {
'worklet'
return [1, 0, 0, 0, 1, 0, 0, 0, 1];
}
function prependTranslate(t: Matrix3, x: number, y: number) {
"worklet";
t[2] += t[0] * x + t[1] * y;
t[5] += t[3] * x + t[4] * y;
}
function prependScale(t: Matrix3, value: number) {
'worklet'
t[0] *= value;
t[1] *= value;
t[3] *= value;
t[4] *= value;
}
function prependTransform(ta: Matrix3, tb: Matrix3) {
"worklet";
const a00 = ta[0], a01 = ta[1], a02 = ta[2];
const a10 = ta[3], a11 = ta[4], a12 = ta[5];
const a20 = ta[6], a21 = ta[7], a22 = ta[8];
ta[0] = a00 * tb[0] + a01 * tb[3] + a02 * tb[6];
ta[1] = a00 * tb[1] + a01 * tb[4] + a02 * tb[7];
ta[2] = a00 * tb[2] + a01 * tb[5] + a02 * tb[8];
ta[3] = a10 * tb[0] + a11 * tb[3] + a12 * tb[6];
ta[4] = a10 * tb[1] + a11 * tb[4] + a12 * tb[7];
ta[5] = a10 * tb[2] + a11 * tb[5] + a12 * tb[8];
ta[6] = a20 * tb[0] + a21 * tb[3] + a22 * tb[6];
ta[7] = a20 * tb[1] + a21 * tb[4] + a22 * tb[7];
ta[8] = a20 * tb[2] + a21 * tb[5] + a22 * tb[8];
}
function readTransform(t): [number, number, number] {
'worklet'
const scale = t[0]
const translateX = t[2]
const translateY = t[5]
return [translateX, translateY, scale]
}
function prependPan(t, translation) {
'worklet'
prependTranslate(t, translation.x, translation.y);
}
function prependPinch(t, scale, origin, translation) {
'worklet'
prependTranslate(t, translation.x, translation.y);
prependTranslate(t, origin.x, origin.y);
prependScale(t, scale);
prependTranslate(t, -origin.x, -origin.y);
}
function toStyle(t) {
"worklet";
const [translateX, translateY, scale] = readTransform(t)
return {
transform: [
{ translateX },
{ translateY },
{ scale }
],
};
}
function getScaledDimensions(imageDimensions, scale) {
'worklet'
const imageAspect = imageDimensions.width / imageDimensions.height
const screenAspect = SCREEN.width / SCREEN.height
const isLandscape = imageAspect > screenAspect
if (isLandscape) {
return {
width: scale * SCREEN.width,
height: scale * SCREEN.width / imageAspect
}
} else {
return {
width: scale * SCREEN.height * imageAspect,
height: scale * SCREEN.height
}
}
}
function clampTranslation(value, scaledSize, screenSize) {
'worklet'
const panDistance = Math.max(0, (scaledSize - screenSize) / 2)
const clampedValue = Math.min(Math.max(-panDistance, value), panDistance)
return clampedValue
}
const initialTransform = createTransform();
const ImageItem = ({
imageSrc,
@@ -53,104 +154,111 @@ const ImageItem = ({
swipeToCloseEnabled = true,
doubleTapToZoomEnabled = true,
}: Props) => {
const imageContainer = useRef<ScrollView & NativeMethodsMixin>(null)
const imageDimensions = useImageDimensions(imageSrc)
const [translate, scale] = getImageTransform(imageDimensions, SCREEN)
const scrollValueY = new Animated.Value(0)
const [isLoaded, setLoadEnd] = useState(false)
const committedTransform = useSharedValue(initialTransform)
const pinchOrigin = useSharedValue({ x: 0, y: 0 })
const pinchScale = useSharedValue(1)
const pinchTranslation = useSharedValue({ x: 0, y: 0 })
const panTranslation = useSharedValue({ x: 0, y: 0 })
const onLoaded = useCallback(() => setLoadEnd(true), [])
const onZoomPerformed = useCallback(
(isZoomed: boolean) => {
onZoom(isZoomed)
if (imageContainer?.current) {
imageContainer.current.setNativeProps({
scrollEnabled: !isZoomed,
})
}
},
[onZoom],
)
const onLongPressHandler = useCallback(() => {
onLongPress(imageSrc)
}, [imageSrc, onLongPress])
const [panHandlers, scaleValue, translateValue] = usePanResponder({
initialScale: scale || 1,
initialTranslate: translate || {x: 0, y: 0},
onZoom: onZoomPerformed,
doubleTapToZoomEnabled,
onLongPress: onLongPressHandler,
delayLongPress,
const animatedStyle = useAnimatedStyle(() => {
let t = createTransform();
prependPan(t, panTranslation.value);
prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value);
prependTransform(t, committedTransform.value);
return toStyle(t)
})
const imagesStyles = getImageStyles(
imageDimensions,
translateValue,
scaleValue,
)
const imageOpacity = scrollValueY.interpolate({
inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
outputRange: [0.7, 1, 0.7],
})
const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity}
const onScrollEndDrag = ({
nativeEvent,
}: NativeSyntheticEvent<NativeScrollEvent>) => {
const velocityY = nativeEvent?.velocity?.y ?? 0
const offsetY = nativeEvent?.contentOffset?.y ?? 0
if (
(Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY &&
offsetY > SWIPE_CLOSE_OFFSET) ||
offsetY > SCREEN_HEIGHT / 2
) {
onRequestClose()
function getPanLimitAdjustment(nextPanTranslation) {
'worklet';
if (!imageDimensions) {
return [0, 0]
}
let t = createTransform();
prependPan(t, nextPanTranslation)
prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value);
prependTransform(t, committedTransform.value)
const [nextTranslateX, nextTranslateY, nextScale] = readTransform(t)
const scaledDimensions = getScaledDimensions(imageDimensions, nextScale)
const clampedTranslateX = clampTranslation(nextTranslateX, scaledDimensions.width, SCREEN.width)
const clampedTranslateY = clampTranslation(nextTranslateY, scaledDimensions.height, SCREEN.height)
const dx = clampedTranslateX - nextTranslateX
const dy = clampedTranslateY - nextTranslateY
return [dx, dy]
}
const onScroll = ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => {
const offsetY = nativeEvent?.contentOffset?.y ?? 0
const pinch = Gesture.Pinch()
.onStart((e) => {
pinchOrigin.value = {
x: e.focalX - SCREEN.width/2,
y: e.focalY - SCREEN.height/2
};
})
.onChange((e) => {
const [,,committedScale] = readTransform(committedTransform.value)
const nextScale = Math.max(e.scale, 1 / committedScale);
pinchScale.value = nextScale
const [dx, dy] = getPanLimitAdjustment(panTranslation.value)
if (dx !== 0 || dy !== 0) {
pinchTranslation.value = {
x: pinchTranslation.value.x + dx,
y: pinchTranslation.value.y + dy
}
}
})
.onEnd(() => {
let t = createTransform();
prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value)
prependTransform(t, committedTransform.value);
committedTransform.value = t
pinchScale.value = 1;
pinchOrigin.value = { x: 0, y: 0 };
pinchTranslation.value = { x: 0, y: 0 };
});
scrollValueY.setValue(offsetY)
}
const pan = Gesture.Pan()
.averageTouches(true)
.onChange((e) => {
if (!imageDimensions) {
return;
}
const nextPanTranslation = { x: e.translationX, y: e.translationY }
const [dx, dy] = getPanLimitAdjustment(nextPanTranslation)
nextPanTranslation.x += dx
nextPanTranslation.y += dy
panTranslation.value = nextPanTranslation;
})
.onEnd(() => {
let t = createTransform();
prependPan(t, panTranslation.value)
prependTransform(t, committedTransform.value);
committedTransform.value = t
panTranslation.value = { x: 0, y: 0 };
});
return (
<ScrollView
ref={imageContainer}
style={styles.listItem}
pagingEnabled
nestedScrollEnabled
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.imageScrollContainer}
scrollEnabled={swipeToCloseEnabled}
{...(swipeToCloseEnabled && {
onScroll,
onScrollEndDrag,
})}>
<AnimatedImage
{...panHandlers}
source={imageSrc}
style={imageStylesWithOpacity}
onLoad={onLoaded}
accessibilityLabel={imageSrc.alt}
accessibilityHint=""
/>
{(!isLoaded || !imageDimensions) && <ImageLoading />}
</ScrollView>
)
<View
style={styles.container}>
<GestureDetector gesture={Gesture.Simultaneous(pinch, pan)}>
<AnimatedImage
source={imageSrc}
resizeMode="contain"
style={[styles.image, animatedStyle]}
accessibilityLabel={imageSrc.alt}
accessibilityHint=""
/>
</GestureDetector>
</View>
)
}
const styles = StyleSheet.create({
listItem: {
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT,
container: {
width: SCREEN.width,
height: SCREEN.height,
},
imageScrollContainer: {
height: SCREEN_HEIGHT * 2,
image: {
flex: 1,
},
})
@@ -1,431 +0,0 @@
/**
* Copyright (c) JOB TODAY S.A. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import {useEffect} from 'react'
import {
Animated,
Dimensions,
GestureResponderEvent,
GestureResponderHandlers,
NativeTouchEvent,
PanResponder,
PanResponderGestureState,
} from 'react-native'
import {Position} from '../@types'
import {
getDistanceBetweenTouches,
getImageTranslate,
getImageDimensionsByTranslate,
} from '../utils'
const SCREEN = Dimensions.get('window')
const SCREEN_WIDTH = SCREEN.width
const SCREEN_HEIGHT = SCREEN.height
const MIN_DIMENSION = Math.min(SCREEN_WIDTH, SCREEN_HEIGHT)
const ANDROID_BAR_HEIGHT = 24
const MIN_ZOOM = 2
const MAX_SCALE = 2
const DOUBLE_TAP_DELAY = 300
const OUT_BOUND_MULTIPLIER = 0.75
type Props = {
initialScale: number
initialTranslate: Position
onZoom: (isZoomed: boolean) => void
doubleTapToZoomEnabled: boolean
onLongPress: () => void
delayLongPress: number
}
const usePanResponder = ({
initialScale,
initialTranslate,
onZoom,
doubleTapToZoomEnabled,
onLongPress,
delayLongPress,
}: Props): Readonly<
[GestureResponderHandlers, Animated.Value, Animated.ValueXY]
> => {
let numberInitialTouches = 1
let initialTouches: NativeTouchEvent[] = []
let currentScale = initialScale
let currentTranslate = initialTranslate
let tmpScale = 0
let tmpTranslate: Position | null = null
let isDoubleTapPerformed = false
let lastTapTS: number | null = null
let longPressHandlerRef: NodeJS.Timeout | null = null
const meaningfulShift = MIN_DIMENSION * 0.01
const scaleValue = new Animated.Value(initialScale)
const translateValue = new Animated.ValueXY(initialTranslate)
const imageDimensions = getImageDimensionsByTranslate(
initialTranslate,
SCREEN,
)
const getBounds = (scale: number) => {
const scaledImageDimensions = {
width: imageDimensions.width * scale,
height: imageDimensions.height * scale,
}
const translateDelta = getImageTranslate(scaledImageDimensions, SCREEN)
const left = initialTranslate.x - translateDelta.x
const right = left - (scaledImageDimensions.width - SCREEN.width)
const top = initialTranslate.y - translateDelta.y
const bottom = top - (scaledImageDimensions.height - SCREEN.height)
return [top, left, bottom, right]
}
const getTransformAfterDoubleTap = (
touchX: number,
touchY: number,
): [number, Position] => {
let nextScale = initialScale
let nextTranslateX = initialTranslate.x
let nextTranslateY = initialTranslate.y
// First, let's figure out how much we want to zoom in.
// We want to try to zoom in at least close enough to get rid of black bars.
const imageAspect = imageDimensions.width / imageDimensions.height
const screenAspect = SCREEN.width / SCREEN.height
let zoom = Math.max(
imageAspect / screenAspect,
screenAspect / imageAspect,
MIN_ZOOM,
)
// Don't zoom so hard that the original image's pixels become blurry.
zoom = Math.min(zoom, MAX_SCALE / initialScale)
nextScale = initialScale * zoom
// Next, let's see if we need to adjust the scaled image translation.
// Ideally, we want the tapped point to stay under the finger after the scaling.
const dx = SCREEN.width / 2 - touchX
const dy = SCREEN.height / 2 - (touchY - ANDROID_BAR_HEIGHT)
// Before we try to adjust the translation, check how much wiggle room we have.
// We don't want to introduce new black bars or make existing black bars unbalanced.
const [topBound, leftBound, bottomBound, rightBound] = getBounds(nextScale)
if (leftBound > rightBound) {
// Content fills the screen horizontally so we have horizontal wiggle room.
// Try to keep the tapped point under the finger after zoom.
nextTranslateX += dx * zoom - dx
nextTranslateX = Math.min(nextTranslateX, leftBound)
nextTranslateX = Math.max(nextTranslateX, rightBound)
}
if (topBound > bottomBound) {
// Content fills the screen vertically so we have vertical wiggle room.
// Try to keep the tapped point under the finger after zoom.
nextTranslateY += dy * zoom - dy
nextTranslateY = Math.min(nextTranslateY, topBound)
nextTranslateY = Math.max(nextTranslateY, bottomBound)
}
return [
nextScale,
{
x: nextTranslateX,
y: nextTranslateY,
},
]
}
const fitsScreenByWidth = () =>
imageDimensions.width * currentScale < SCREEN_WIDTH
const fitsScreenByHeight = () =>
imageDimensions.height * currentScale < SCREEN_HEIGHT
useEffect(() => {
scaleValue.addListener(({value}) => {
if (typeof onZoom === 'function') {
onZoom(value !== initialScale)
}
})
return () => scaleValue.removeAllListeners()
})
const cancelLongPressHandle = () => {
longPressHandlerRef && clearTimeout(longPressHandlerRef)
}
const panResponder = PanResponder.create({
onStartShouldSetPanResponder: () => true,
onStartShouldSetPanResponderCapture: () => true,
onMoveShouldSetPanResponder: () => true,
onMoveShouldSetPanResponderCapture: () => true,
onPanResponderGrant: (
_: GestureResponderEvent,
gestureState: PanResponderGestureState,
) => {
numberInitialTouches = gestureState.numberActiveTouches
if (gestureState.numberActiveTouches > 1) {
return
}
longPressHandlerRef = setTimeout(onLongPress, delayLongPress)
},
onPanResponderStart: (
event: GestureResponderEvent,
gestureState: PanResponderGestureState,
) => {
initialTouches = event.nativeEvent.touches
numberInitialTouches = gestureState.numberActiveTouches
if (gestureState.numberActiveTouches > 1) {
return
}
const tapTS = Date.now()
// Handle double tap event by calculating diff between first and second taps timestamps
isDoubleTapPerformed = Boolean(
lastTapTS && tapTS - lastTapTS < DOUBLE_TAP_DELAY,
)
if (doubleTapToZoomEnabled && isDoubleTapPerformed) {
let nextScale = initialScale
let nextTranslate = initialTranslate
const willZoom = currentScale === initialScale
if (willZoom) {
const {pageX: touchX, pageY: touchY} = event.nativeEvent.touches[0]
;[nextScale, nextTranslate] = getTransformAfterDoubleTap(
touchX,
touchY,
)
}
onZoom(willZoom)
Animated.parallel(
[
Animated.timing(translateValue.x, {
toValue: nextTranslate.x,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(translateValue.y, {
toValue: nextTranslate.y,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(scaleValue, {
toValue: nextScale,
duration: 300,
useNativeDriver: true,
}),
],
{stopTogether: false},
).start(() => {
currentScale = nextScale
currentTranslate = nextTranslate
})
lastTapTS = null
} else {
lastTapTS = Date.now()
}
},
onPanResponderMove: (
event: GestureResponderEvent,
gestureState: PanResponderGestureState,
) => {
const {dx, dy} = gestureState
if (Math.abs(dx) >= meaningfulShift || Math.abs(dy) >= meaningfulShift) {
cancelLongPressHandle()
}
// Don't need to handle move because double tap in progress (was handled in onStart)
if (doubleTapToZoomEnabled && isDoubleTapPerformed) {
cancelLongPressHandle()
return
}
if (
numberInitialTouches === 1 &&
gestureState.numberActiveTouches === 2
) {
numberInitialTouches = 2
initialTouches = event.nativeEvent.touches
}
const isTapGesture =
numberInitialTouches === 1 && gestureState.numberActiveTouches === 1
const isPinchGesture =
numberInitialTouches === 2 && gestureState.numberActiveTouches === 2
if (isPinchGesture) {
cancelLongPressHandle()
const initialDistance = getDistanceBetweenTouches(initialTouches)
const currentDistance = getDistanceBetweenTouches(
event.nativeEvent.touches,
)
let nextScale = (currentDistance / initialDistance) * currentScale
/**
* In case image is scaling smaller than initial size ->
* slow down this transition by applying OUT_BOUND_MULTIPLIER
*/
if (nextScale < initialScale) {
nextScale =
nextScale + (initialScale - nextScale) * OUT_BOUND_MULTIPLIER
}
/**
* In case image is scaling down -> move it in direction of initial position
*/
if (currentScale > initialScale && currentScale > nextScale) {
const k = (currentScale - initialScale) / (currentScale - nextScale)
const nextTranslateX =
nextScale < initialScale
? initialTranslate.x
: currentTranslate.x -
(currentTranslate.x - initialTranslate.x) / k
const nextTranslateY =
nextScale < initialScale
? initialTranslate.y
: currentTranslate.y -
(currentTranslate.y - initialTranslate.y) / k
translateValue.x.setValue(nextTranslateX)
translateValue.y.setValue(nextTranslateY)
tmpTranslate = {x: nextTranslateX, y: nextTranslateY}
}
scaleValue.setValue(nextScale)
tmpScale = nextScale
}
if (isTapGesture && currentScale > initialScale) {
const {x, y} = currentTranslate
// eslint-disable-next-line @typescript-eslint/no-shadow
const {dx, dy} = gestureState
const [topBound, leftBound, bottomBound, rightBound] =
getBounds(currentScale)
let nextTranslateX = x + dx
let nextTranslateY = y + dy
if (nextTranslateX > leftBound) {
nextTranslateX =
nextTranslateX - (nextTranslateX - leftBound) * OUT_BOUND_MULTIPLIER
}
if (nextTranslateX < rightBound) {
nextTranslateX =
nextTranslateX -
(nextTranslateX - rightBound) * OUT_BOUND_MULTIPLIER
}
if (nextTranslateY > topBound) {
nextTranslateY =
nextTranslateY - (nextTranslateY - topBound) * OUT_BOUND_MULTIPLIER
}
if (nextTranslateY < bottomBound) {
nextTranslateY =
nextTranslateY -
(nextTranslateY - bottomBound) * OUT_BOUND_MULTIPLIER
}
if (fitsScreenByWidth()) {
nextTranslateX = x
}
if (fitsScreenByHeight()) {
nextTranslateY = y
}
translateValue.x.setValue(nextTranslateX)
translateValue.y.setValue(nextTranslateY)
tmpTranslate = {x: nextTranslateX, y: nextTranslateY}
}
},
onPanResponderRelease: () => {
cancelLongPressHandle()
if (isDoubleTapPerformed) {
isDoubleTapPerformed = false
}
if (tmpScale > 0) {
if (tmpScale < initialScale || tmpScale > MAX_SCALE) {
tmpScale = tmpScale < initialScale ? initialScale : MAX_SCALE
Animated.timing(scaleValue, {
toValue: tmpScale,
duration: 100,
useNativeDriver: true,
}).start()
}
currentScale = tmpScale
tmpScale = 0
}
if (tmpTranslate) {
const {x, y} = tmpTranslate
const [topBound, leftBound, bottomBound, rightBound] =
getBounds(currentScale)
let nextTranslateX = x
let nextTranslateY = y
if (!fitsScreenByWidth()) {
if (nextTranslateX > leftBound) {
nextTranslateX = leftBound
} else if (nextTranslateX < rightBound) {
nextTranslateX = rightBound
}
}
if (!fitsScreenByHeight()) {
if (nextTranslateY > topBound) {
nextTranslateY = topBound
} else if (nextTranslateY < bottomBound) {
nextTranslateY = bottomBound
}
}
Animated.parallel([
Animated.timing(translateValue.x, {
toValue: nextTranslateX,
duration: 100,
useNativeDriver: true,
}),
Animated.timing(translateValue.y, {
toValue: nextTranslateY,
duration: 100,
useNativeDriver: true,
}),
]).start()
currentTranslate = {x: nextTranslateX, y: nextTranslateY}
tmpTranslate = null
}
},
onPanResponderTerminationRequest: () => false,
onShouldBlockNativeResponder: () => false,
})
return [panResponder.panHandlers, scaleValue, translateValue]
}
export default usePanResponder
+9 -34
View File
@@ -129,40 +129,15 @@ function ImageViewing({
<ImageDefaultHeader onRequestClose={onRequestCloseEnhanced} />
)}
</Animated.View>
<VirtualizedList
ref={imageList}
data={images}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
getItem={(_, index) => images[index]}
getItemCount={() => images.length}
getItemLayout={(_, index) => ({
length: SCREEN_WIDTH,
offset: SCREEN_WIDTH * index,
index,
})}
renderItem={({item: imageSrc}) => (
<ImageItem
onZoom={onZoom}
imageSrc={imageSrc}
onRequestClose={onRequestCloseEnhanced}
onLongPress={onLongPress}
delayLongPress={delayLongPress}
swipeToCloseEnabled={swipeToCloseEnabled}
doubleTapToZoomEnabled={doubleTapToZoomEnabled}
/>
)}
onMomentumScrollEnd={onScroll}
//@ts-ignore
keyExtractor={(imageSrc, index) =>
keyExtractor
? keyExtractor(imageSrc, index)
: typeof imageSrc === 'number'
? `${imageSrc}`
: imageSrc.uri
}
{/* TODO: show gallery again */}
<ImageItem
onZoom={onZoom}
imageSrc={images[0]}
onRequestClose={onRequestCloseEnhanced}
onLongPress={onLongPress}
delayLongPress={delayLongPress}
swipeToCloseEnabled={swipeToCloseEnabled}
doubleTapToZoomEnabled={doubleTapToZoomEnabled}
/>
{typeof FooterComponent !== 'undefined' && (
<Animated.View style={[styles.footer, {transform: footerTransform}]}>