From 44733bb39059e25d2267369b9b878f2c8b0e4678 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Thu, 28 Sep 2023 18:04:56 +0100 Subject: [PATCH] [WIP] Rewrite Android lightbox --- .../ImageItem/ImageItem.android.tsx | 312 ++++++++----- .../ImageViewing/hooks/usePanResponder.ts | 431 ------------------ src/view/com/lightbox/ImageViewing/index.tsx | 43 +- 3 files changed, 219 insertions(+), 567 deletions(-) delete mode 100644 src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx index f5e858209e..3385933ca4 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx @@ -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(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) => { - 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) => { - 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 ( - - - {(!isLoaded || !imageDimensions) && } - - ) + + + + + + ) } 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, }, }) diff --git a/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts b/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts deleted file mode 100644 index 7908504eab..0000000000 --- a/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts +++ /dev/null @@ -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 diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx index 1a64fb3afb..8583047fca 100644 --- a/src/view/com/lightbox/ImageViewing/index.tsx +++ b/src/view/com/lightbox/ImageViewing/index.tsx @@ -129,40 +129,15 @@ function ImageViewing({ )} - images[index]} - getItemCount={() => images.length} - getItemLayout={(_, index) => ({ - length: SCREEN_WIDTH, - offset: SCREEN_WIDTH * index, - index, - })} - renderItem={({item: imageSrc}) => ( - - )} - onMomentumScrollEnd={onScroll} - //@ts-ignore - keyExtractor={(imageSrc, index) => - keyExtractor - ? keyExtractor(imageSrc, index) - : typeof imageSrc === 'number' - ? `${imageSrc}` - : imageSrc.uri - } + {/* TODO: show gallery again */} + {typeof FooterComponent !== 'undefined' && (