Move image viewer to a new plugin

This commit is contained in:
xmflsct 2022-09-14 21:52:16 +02:00
parent b537c38e9c
commit 969d4abe0e
16 changed files with 128 additions and 1273 deletions

View File

@ -86,6 +86,7 @@
"react-native-language-detection": "^0.1.0",
"react-native-pager-view": "^5.4.25",
"react-native-reanimated": "^2.9.1",
"react-native-reanimated-zoom": "^0.3.0",
"react-native-safe-area-context": "^4.3.1",
"react-native-screens": "^3.16.0",
"react-native-share-menu": "^6.0.0",

View File

@ -67,9 +67,7 @@ const GracefullyImage = ({
const onLoad = () => {
setImageLoaded(true)
if (setImageDimensions && source.uri) {
Image.getSize(source.uri, (width, height) =>
setImageDimensions({ width, height })
)
Image.getSize(source.uri, (width, height) => setImageDimensions({ width, height }))
}
}
const onError = () => {
@ -81,22 +79,9 @@ const GracefullyImage = ({
const blurhashView = useMemo(() => {
if (hidden || !imageLoaded) {
if (blurhash) {
return (
<Blurhash
decodeAsync
blurhash={blurhash}
style={styles.placeholder}
/>
)
return <Blurhash decodeAsync blurhash={blurhash} style={styles.placeholder} />
} else {
return (
<View
style={[
styles.placeholder,
{ backgroundColor: colors.shimmerDefault }
]}
/>
)
return <View style={[styles.placeholder, { backgroundColor: colors.shimmerDefault }]} />
}
} else {
return null
@ -105,26 +90,17 @@ const GracefullyImage = ({
return (
<Pressable
{...(onPress
? { accessibilityRole: 'imagebutton' }
: { accessibilityRole: 'image' })}
{...(onPress ? { accessibilityRole: 'imagebutton' } : { accessibilityRole: 'image' })}
accessibilityLabel={accessibilityLabel}
accessibilityHint={accessibilityHint}
style={[style, dimension, { backgroundColor: colors.shimmerDefault }]}
{...(onPress
? hidden
? { disabled: true }
: { onPress }
: { disabled: true })}
{...(onPress ? (hidden ? { disabled: true } : { onPress }) : { disabled: true })}
>
{uri.preview && !imageLoaded ? (
<Image
fadeDuration={0}
source={{ uri: uri.preview }}
style={[
styles.placeholder,
{ backgroundColor: colors.shimmerDefault }
]}
style={[styles.placeholder, { backgroundColor: colors.shimmerDefault }]}
/>
) : null}
{Platform.OS === 'ios' ? (

View File

@ -1,7 +0,0 @@
import * as rn from "react-native";
declare module "react-native" {
class VirtualizedList<ItemT> extends React.Component<
VirtualizedListProps<ItemT>
> {}
}

View File

@ -1,17 +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.
*
*/
export type Dimensions = {
width: number
height: number
}
export type Position = {
x: number
y: number
}

View File

@ -1,132 +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 { RootStackParamList } from '@utils/navigation/navigators'
import React, { ComponentType, useCallback, useEffect } from 'react'
import {
Animated,
Dimensions,
StyleSheet,
View,
VirtualizedList
} from 'react-native'
import ImageItem from './components/ImageItem'
import useAnimatedComponents from './hooks/useAnimatedComponents'
import useImageIndexChange from './hooks/useImageIndexChange'
import useRequestClose from './hooks/useRequestClose'
type Props = {
images: RootStackParamList['Screen-ImagesViewer']['imageUrls']
imageIndex: number
onRequestClose: () => void
onLongPress?: (
image: RootStackParamList['Screen-ImagesViewer']['imageUrls'][0]
) => void
onImageIndexChange?: (imageIndex: number) => void
backgroundColor?: string
swipeToCloseEnabled?: boolean
delayLongPress?: number
HeaderComponent: ComponentType<{ imageIndex: number }>
}
const DEFAULT_BG_COLOR = '#000'
const DEFAULT_DELAY_LONG_PRESS = 800
const SCREEN = Dimensions.get('screen')
const SCREEN_WIDTH = SCREEN.width
function ImageViewer ({
images,
imageIndex,
onRequestClose,
onLongPress = () => {},
onImageIndexChange,
backgroundColor = DEFAULT_BG_COLOR,
swipeToCloseEnabled,
delayLongPress = DEFAULT_DELAY_LONG_PRESS,
HeaderComponent
}: Props) {
const imageList = React.createRef<
VirtualizedList<RootStackParamList['Screen-ImagesViewer']['imageUrls'][0]>
>()
const [opacity, onRequestCloseEnhanced] = useRequestClose(onRequestClose)
const [currentImageIndex, onScroll] = useImageIndexChange(imageIndex, SCREEN)
const [headerTransform, toggleBarsVisible] = useAnimatedComponents()
useEffect(() => {
if (onImageIndexChange) {
onImageIndexChange(currentImageIndex)
}
}, [currentImageIndex])
const onZoom = useCallback(
(isScaled: boolean) => {
// @ts-ignore
imageList?.current?.setNativeProps({ scrollEnabled: !isScaled })
toggleBarsVisible(!isScaled)
},
[imageList]
)
return (
<View style={[styles.container, { opacity, backgroundColor }]}>
<Animated.View style={[styles.header, { transform: headerTransform }]}>
{React.createElement(HeaderComponent, {
imageIndex: currentImageIndex
})}
</Animated.View>
<VirtualizedList
ref={imageList}
data={images}
horizontal
pagingEnabled
windowSize={2}
initialNumToRender={1}
maxToRenderPerBatch={1}
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
initialScrollIndex={
imageIndex > images.length - 1 ? images.length - 1 : imageIndex
}
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}
/>
)}
onMomentumScrollEnd={onScroll}
keyExtractor={imageSrc => imageSrc.url}
/>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000'
},
header: {
position: 'absolute',
width: '100%',
zIndex: 1,
top: 0
}
})
export default ImageViewer

View File

@ -1,123 +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 GracefullyImage from '@components/GracefullyImage'
import { RootStackParamList } from '@utils/navigation/navigators'
import React, { useState, useCallback } from 'react'
import { Animated, Dimensions, StyleSheet } from 'react-native'
import usePanResponder from '../hooks/usePanResponder'
import { getImageStyles, getImageTransform } from '../utils'
const SCREEN = Dimensions.get('window')
const SCREEN_WIDTH = SCREEN.width
const SCREEN_HEIGHT = SCREEN.height
type Props = {
imageSrc: RootStackParamList['Screen-ImagesViewer']['imageUrls'][0]
onRequestClose: () => void
onZoom: (isZoomed: boolean) => void
onLongPress: (
image: RootStackParamList['Screen-ImagesViewer']['imageUrls'][0]
) => void
delayLongPress: number
swipeToCloseEnabled?: boolean
doubleTapToZoomEnabled?: boolean
}
const ImageItem = ({
imageSrc,
onZoom,
onRequestClose,
onLongPress,
delayLongPress,
doubleTapToZoomEnabled = true
}: Props) => {
const imageContainer = React.createRef<any>()
const [imageDimensions, setImageDimensions] = useState({
width: imageSrc.width || 0,
height: imageSrc.height || 0
})
const [translate, scale] = getImageTransform(imageDimensions, SCREEN)
const onZoomPerformed = (isZoomed: boolean) => {
onZoom(isZoomed)
if (imageContainer?.current) {
// @ts-ignore
imageContainer.current.setNativeProps({
scrollEnabled: !isZoomed
})
}
}
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,
onRequestClose
})
const imagesStyles = getImageStyles(
imageDimensions,
translateValue,
scaleValue
)
return (
<Animated.ScrollView
ref={imageContainer}
style={styles.listItem}
pagingEnabled
nestedScrollEnabled
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.imageScrollContainer}
scrollEnabled={false}
>
<Animated.View
{...panHandlers}
style={imagesStyles}
children={
<GracefullyImage
uri={{
preview: imageSrc.preview_url,
original: imageSrc.url,
remote: imageSrc.remote_url
}}
{...((!imageSrc.width || !imageSrc.height) && {
setImageDimensions
})}
style={{ flex: 1 }}
imageStyle={{
flex: 1,
resizeMode: 'stretch'
}}
/>
}
/>
</Animated.ScrollView>
)
}
const styles = StyleSheet.create({
listItem: {
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT
},
imageScrollContainer: {
height: SCREEN_HEIGHT * 2
}
})
export default React.memo(ImageItem)

View File

@ -1,32 +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 React from "react";
import { GestureResponderEvent } from "react-native";
import { ImageSource } from "../@types";
declare type Props = {
imageSrc: ImageSource;
onRequestClose: () => void;
onZoom: (isZoomed: boolean) => void;
onLongPress: (image: ImageSource) => void;
delayLongPress: number;
swipeToCloseEnabled?: boolean;
doubleTapToZoomEnabled?: boolean;
};
declare const _default: React.MemoExoticComponent<({
imageSrc,
onZoom,
onRequestClose,
onLongPress,
delayLongPress,
swipeToCloseEnabled,
}: Props) => JSX.Element>;
export default _default;

View File

@ -1,177 +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 GracefullyImage from '@components/GracefullyImage'
import { RootStackParamList } from '@utils/navigation/navigators'
import React, { createRef, useCallback, useRef, useState } from 'react'
import {
Animated,
Dimensions,
NativeScrollEvent,
NativeSyntheticEvent,
ScrollView,
StyleSheet
} from 'react-native'
import {
LongPressGestureHandler,
State,
TapGestureHandler
} from 'react-native-gesture-handler'
import useDoubleTapToZoom from '../hooks/useDoubleTapToZoom'
import { getImageStyles, getImageTransform } from '../utils'
const SWIPE_CLOSE_OFFSET = 75
const SWIPE_CLOSE_VELOCITY = 0.55
const SCREEN = Dimensions.get('screen')
const SCREEN_WIDTH = SCREEN.width
const SCREEN_HEIGHT = SCREEN.height
type Props = {
imageSrc: RootStackParamList['Screen-ImagesViewer']['imageUrls'][0]
onRequestClose: () => void
onZoom: (scaled: boolean) => void
onLongPress: (
image: RootStackParamList['Screen-ImagesViewer']['imageUrls'][0]
) => void
swipeToCloseEnabled?: boolean
}
const doubleTap = createRef()
const ImageItem = ({
imageSrc,
onZoom,
onRequestClose,
onLongPress,
swipeToCloseEnabled = true
}: Props) => {
const scrollViewRef = useRef<ScrollView>(null)
const [scaled, setScaled] = useState(false)
const [imageDimensions, setImageDimensions] = useState({
width: imageSrc.width || 1,
height: imageSrc.height || 1
})
const handleDoubleTap = useDoubleTapToZoom(scrollViewRef, scaled, SCREEN)
const [translate, scale] = getImageTransform(imageDimensions, SCREEN)
const scrollValueY = new Animated.Value(0)
const scaleValue = new Animated.Value(scale || 1)
const translateValue = new Animated.ValueXY(translate)
const maxScale = scale && scale > 0 ? Math.max(1 / scale, 1) : 1
const imageOpacity = scrollValueY.interpolate({
inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
outputRange: [0.5, 1, 0.5]
})
const imagesStyles = getImageStyles(
imageDimensions,
translateValue,
scaleValue
)
const imageStylesWithOpacity = { ...imagesStyles, opacity: imageOpacity }
const onScrollEndDrag = useCallback(
({ nativeEvent }: NativeSyntheticEvent<NativeScrollEvent>) => {
const velocityY = nativeEvent?.velocity?.y ?? 0
const scaled = nativeEvent?.zoomScale > 1
onZoom(scaled)
setScaled(scaled)
if (
!scaled &&
swipeToCloseEnabled &&
Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY
) {
onRequestClose()
}
},
[scaled]
)
const onScroll = ({
nativeEvent
}: NativeSyntheticEvent<NativeScrollEvent>) => {
const offsetY = nativeEvent?.contentOffset?.y ?? 0
if (nativeEvent?.zoomScale > 1) {
return
}
scrollValueY.setValue(offsetY)
}
return (
<LongPressGestureHandler
onHandlerStateChange={({ nativeEvent }) => {
if (nativeEvent.state === State.ACTIVE) {
onLongPress(imageSrc)
}
}}
>
<TapGestureHandler
onHandlerStateChange={({ nativeEvent }) =>
nativeEvent.state === State.ACTIVE && onRequestClose()
}
waitFor={doubleTap}
>
<TapGestureHandler
ref={doubleTap}
onHandlerStateChange={handleDoubleTap}
numberOfTaps={2}
>
<ScrollView
ref={scrollViewRef}
style={styles.listItem}
pinchGestureEnabled
nestedScrollEnabled={true}
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
maximumZoomScale={maxScale}
contentContainerStyle={styles.imageScrollContainer}
scrollEnabled={swipeToCloseEnabled}
onScrollEndDrag={onScrollEndDrag}
scrollEventThrottle={1}
{...(swipeToCloseEnabled && {
onScroll
})}
>
<Animated.View
style={imageStylesWithOpacity}
children={
<GracefullyImage
uri={{
preview: imageSrc.preview_url,
original: imageSrc.url,
remote: imageSrc.remote_url
}}
{...((!imageSrc.width || !imageSrc.height) && {
setImageDimensions
})}
style={{ flex: 1 }}
/>
}
/>
</ScrollView>
</TapGestureHandler>
</TapGestureHandler>
</LongPressGestureHandler>
)
}
const styles = StyleSheet.create({
listItem: {
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT
},
imageScrollContainer: {
height: SCREEN_HEIGHT
}
})
export default React.memo(ImageItem)

View File

@ -1,40 +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 { Animated } from 'react-native'
const INITIAL_POSITION = { x: 0, y: 0 }
const ANIMATION_CONFIG = {
duration: 200,
useNativeDriver: true
}
const useAnimatedComponents = () => {
const headerTranslate = new Animated.ValueXY(INITIAL_POSITION)
const toggleVisible = (isVisible: boolean) => {
if (isVisible) {
Animated.parallel([
Animated.timing(headerTranslate.y, { ...ANIMATION_CONFIG, toValue: 0 })
]).start()
} else {
Animated.parallel([
Animated.timing(headerTranslate.y, {
...ANIMATION_CONFIG,
toValue: -300
})
]).start()
}
}
const headerTransform = headerTranslate.getTranslateTransform()
return [headerTransform, toggleVisible] as const
}
export default useAnimatedComponents

View File

@ -1,64 +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 React, { useCallback } from 'react'
import { ScrollView } from 'react-native'
import {
HandlerStateChangeEvent,
State,
TapGestureHandlerEventPayload
} from 'react-native-gesture-handler'
import { Dimensions } from '../@types'
/**
* This is iOS only.
* Same functionality for Android implemented inside usePanResponder hook.
*/
function useDoubleTapToZoom (
scrollViewRef: React.RefObject<ScrollView>,
scaled: boolean,
screen: Dimensions
) {
const handleDoubleTap = useCallback(
({
nativeEvent
}: HandlerStateChangeEvent<TapGestureHandlerEventPayload>) => {
if (nativeEvent.state === State.ACTIVE) {
const scrollResponderRef = scrollViewRef?.current?.getScrollResponder()
const { absoluteX, absoluteY } = nativeEvent
let targetX = 0
let targetY = 0
let targetWidth = screen.width
let targetHeight = screen.height
// Zooming in
// TODO: Add more precise calculation of targetX, targetY based on touch
if (!scaled) {
targetX = absoluteX / 2
targetY = absoluteY / 2
targetWidth = screen.width / 2
targetHeight = screen.height / 2
}
scrollResponderRef?.scrollResponderZoomTo({
x: targetX,
y: targetY,
width: targetWidth,
height: targetHeight,
animated: true
})
}
},
[scaled]
)
return handleDoubleTap
}
export default useDoubleTapToZoom

View File

@ -1,31 +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 { useState } from 'react'
import { NativeSyntheticEvent, NativeScrollEvent } from 'react-native'
import { Dimensions } from '../@types'
const useImageIndexChange = (imageIndex: number, screen: Dimensions) => {
const [currentImageIndex, setImageIndex] = useState(imageIndex)
const onScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
const {
nativeEvent: {
contentOffset: { x: scrollX }
}
} = event
if (screen.width) {
const nextIndex = Math.round(scrollX / screen.width)
setImageIndex(nextIndex < 0 ? 0 : nextIndex)
}
}
return [currentImageIndex, onScroll] as const
}
export default useImageIndexChange

View File

@ -1,406 +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 { useMemo, useEffect } from 'react'
import {
Animated,
Dimensions,
GestureResponderEvent,
GestureResponderHandlers,
NativeTouchEvent,
PanResponderGestureState
} from 'react-native'
import { Position } from '../@types'
import {
createPanResponder,
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 SCALE_MAX = 1
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
onRequestClose: () => void
}
const usePanResponder = ({
initialScale,
initialTranslate,
onZoom,
doubleTapToZoomEnabled,
onLongPress,
delayLongPress,
onRequestClose
}: 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 timer: number | null = null
let longPressHandlerRef: number | 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 getTranslateInBounds = (translate: Position, scale: number) => {
const inBoundTranslate = { x: translate.x, y: translate.y }
const [topBound, leftBound, bottomBound, rightBound] = getBounds(scale)
if (translate.x > leftBound) {
inBoundTranslate.x = leftBound
} else if (translate.x < rightBound) {
inBoundTranslate.x = rightBound
}
if (translate.y > topBound) {
inBoundTranslate.y = topBound
} else if (translate.y < bottomBound) {
inBoundTranslate.y = bottomBound
}
return inBoundTranslate
}
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 handlers = {
onGrant: (
_: GestureResponderEvent,
gestureState: PanResponderGestureState
) => {
numberInitialTouches = gestureState.numberActiveTouches
if (gestureState.numberActiveTouches > 1) return
// @ts-ignore
longPressHandlerRef = setTimeout(onLongPress, delayLongPress)
},
onStart: (
event: GestureResponderEvent,
gestureState: PanResponderGestureState
) => {
initialTouches = event.nativeEvent.touches
numberInitialTouches = gestureState.numberActiveTouches
if (gestureState.numberActiveTouches > 1) return
const tapTS = Date.now()
!timer &&
// @ts-ignore
(timer = setTimeout(() => onRequestClose(), DOUBLE_TAP_DELAY + 50))
// Handle double tap event by calculating diff between first and second taps timestamps
isDoubleTapPerformed = Boolean(
lastTapTS && tapTS - lastTapTS < DOUBLE_TAP_DELAY
)
if (doubleTapToZoomEnabled && isDoubleTapPerformed) {
// @ts-ignore
clearTimeout(timer)
const isScaled = currentTranslate.x !== initialTranslate.x // currentScale !== initialScale;
const { pageX: touchX, pageY: touchY } = event.nativeEvent.touches[0]
const targetScale = SCALE_MAX
const nextScale = isScaled ? initialScale : targetScale
const nextTranslate = isScaled
? initialTranslate
: getTranslateInBounds(
{
x:
initialTranslate.x +
(SCREEN_WIDTH / 2 - touchX) * (targetScale / currentScale),
y:
initialTranslate.y +
(SCREEN_HEIGHT / 2 - -touchY) * (targetScale / currentScale)
},
targetScale
)
onZoom(!isScaled)
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
timer = null
} else {
lastTapTS = Date.now()
}
},
onMove: (
event: GestureResponderEvent,
gestureState: PanResponderGestureState
) => {
const { dx, dy } = gestureState
if (Math.abs(dx) >= meaningfulShift || Math.abs(dy) >= meaningfulShift) {
cancelLongPressHandle()
timer && clearTimeout(timer)
}
// Don't need to handle move because double tap in progress (was handled in onStart)
if (doubleTapToZoomEnabled && isDoubleTapPerformed) {
cancelLongPressHandle()
timer && clearTimeout(timer)
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()
timer && clearTimeout(timer)
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
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 }
}
},
onRelease: () => {
cancelLongPressHandle()
if (isDoubleTapPerformed) {
isDoubleTapPerformed = false
}
if (tmpScale > 0) {
if (tmpScale < initialScale || tmpScale > SCALE_MAX) {
tmpScale = tmpScale < initialScale ? initialScale : SCALE_MAX
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
}
}
}
const panResponder = useMemo(() => createPanResponder(handlers), [handlers])
return [panResponder.panHandlers, scaleValue, translateValue]
}
export default usePanResponder

View File

@ -1,23 +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 { useState } from 'react'
const useRequestClose = (onRequestClose: () => void) => {
const [opacity, setOpacity] = useState(1)
return [
opacity,
() => {
setOpacity(0)
onRequestClose()
}
] as const
}
export default useRequestClose

View File

@ -1,147 +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 {
Animated,
GestureResponderEvent,
PanResponder,
PanResponderGestureState,
PanResponderInstance,
NativeTouchEvent
} from 'react-native'
import { Dimensions, Position } from './@types'
export const getImageTransform = (
image: Dimensions | null,
screen: Dimensions
) => {
if (!image?.width || !image?.height) {
return [] as const
}
const wScale = screen.width / image.width
const hScale = screen.height / image.height
const scale = Math.min(wScale, hScale)
const { x, y } = getImageTranslate(image, screen)
return [{ x, y }, scale] as const
}
export const getImageStyles = (
image: Dimensions | null,
translate: Animated.ValueXY,
scale?: Animated.Value
) => {
if (!image?.width || !image?.height) {
return { width: 0, height: 0 }
}
const transform = translate.getTranslateTransform()
if (scale) {
// @ts-ignore
transform.push({ scale }, { perspective: new Animated.Value(1000) })
}
return {
width: image.width,
height: image.height,
transform
}
}
export const getImageTranslate = (
image: Dimensions,
screen: Dimensions
): Position => {
const getTranslateForAxis = (axis: 'x' | 'y'): number => {
const imageSize = axis === 'x' ? image.width : image.height
const screenSize = axis === 'x' ? screen.width : screen.height
return (screenSize - imageSize) / 2
}
return {
x: getTranslateForAxis('x'),
y: getTranslateForAxis('y')
}
}
export const getImageDimensionsByTranslate = (
translate: Position,
screen: Dimensions
): Dimensions => ({
width: screen.width - translate.x * 2,
height: screen.height - translate.y * 2
})
export const getImageTranslateForScale = (
currentTranslate: Position,
targetScale: number,
screen: Dimensions
): Position => {
const { width, height } = getImageDimensionsByTranslate(
currentTranslate,
screen
)
const targetImageDimensions = {
width: width * targetScale,
height: height * targetScale
}
return getImageTranslate(targetImageDimensions, screen)
}
type HandlerType = (
event: GestureResponderEvent,
state: PanResponderGestureState
) => void
type PanResponderProps = {
onGrant: HandlerType
onStart?: HandlerType
onMove: HandlerType
onRelease?: HandlerType
onTerminate?: HandlerType
}
export const createPanResponder = ({
onGrant,
onStart,
onMove,
onRelease,
onTerminate
}: PanResponderProps): PanResponderInstance =>
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onStartShouldSetPanResponderCapture: () => true,
onMoveShouldSetPanResponder: () => true,
onMoveShouldSetPanResponderCapture: () => true,
onPanResponderGrant: onGrant,
onPanResponderStart: onStart,
onPanResponderMove: onMove,
onPanResponderRelease: onRelease,
onPanResponderTerminate: onTerminate,
onPanResponderTerminationRequest: () => false,
onShouldBlockNativeResponder: () => false
})
export const getDistanceBetweenTouches = (
touches: NativeTouchEvent[]
): number => {
const [a, b] = touches
if (a == null || b == null) {
return 0
}
return Math.sqrt(
Math.pow(a.pageX - b.pageX, 2) + Math.pow(a.pageY - b.pageY, 2)
)
}

View File

@ -1,4 +1,5 @@
import analytics from '@components/analytics'
import GracefullyImage from '@components/GracefullyImage'
import { HeaderCenter, HeaderLeft, HeaderRight } from '@components/Header'
import { Message } from '@components/Message'
import { useActionSheet } from '@expo/react-native-action-sheet'
@ -6,15 +7,24 @@ import { RootStackScreenProps } from '@utils/navigation/navigators'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Platform, Share, StatusBar, View } from 'react-native'
import FlashMessage from 'react-native-flash-message'
import {
SafeAreaProvider,
useSafeAreaInsets
} from 'react-native-safe-area-context'
import ImageViewer from './ImageViewer/Root'
Dimensions,
FlatList,
PixelRatio,
Platform,
Share,
StatusBar,
View,
ViewToken
} from 'react-native'
import FlashMessage from 'react-native-flash-message'
import { LongPressGestureHandler } from 'react-native-gesture-handler'
import { Zoom, createZoomListComponent } from 'react-native-reanimated-zoom'
import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'
import saveImage from './ImageViewer/save'
const ZoomFlatList = createZoomListComponent(FlatList)
const ScreenImagesViewer = ({
route: {
params: { imageUrls, id }
@ -26,6 +36,9 @@ const ScreenImagesViewer = ({
return null
}
const SCREEN_WIDTH = Dimensions.get('screen').width
const SCREEN_HEIGHT = Dimensions.get('screen').height
const insets = useSafeAreaInsets()
const { mode, theme } = useTheme()
@ -34,6 +47,7 @@ const ScreenImagesViewer = ({
const initialIndex = imageUrls.findIndex(image => image.id === id)
const [currentIndex, setCurrentIndex] = useState(initialIndex)
const listRef = useRef<FlatList>(null)
const messageRef = useRef<FlashMessage>(null)
const { showActionSheetWithOptions } = useActionSheet()
@ -71,15 +85,84 @@ const ScreenImagesViewer = ({
)
}, [currentIndex])
const renderItem = React.useCallback(
({
item
}: {
item: RootStackScreenProps<'Screen-ImagesViewer'>['route']['params']['imageUrls'][0]
}) => {
const screenRatio = SCREEN_WIDTH / SCREEN_HEIGHT
const imageRatio = item.width && item.height ? item.width / item.height : 1
const maxWidthScale = item.width ? (item.width / SCREEN_WIDTH / PixelRatio.get()) * 4 : 0
const maxHeightScale = item.height ? (item.height / SCREEN_WIDTH / PixelRatio.get()) * 4 : 0
const max = Math.max.apply(Math, [maxWidthScale, maxHeightScale, 4])
return (
<Zoom
maximumZoomScale={max > 8 ? 8 : max}
children={
<View
style={{
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center'
}}
>
<GracefullyImage
uri={{ preview: item.preview_url, remote: item.remote_url, original: item.url }}
blurhash={item.blurhash}
dimension={{
width:
screenRatio > imageRatio ? (item.height || 100) / screenRatio : SCREEN_WIDTH,
height:
screenRatio > imageRatio ? SCREEN_HEIGHT : (item.width || 100) * screenRatio
}}
/>
</View>
}
/>
)
},
[]
)
const onViewableItemsChanged = useCallback(
({ viewableItems }: { viewableItems: ViewToken[] }) => {
setCurrentIndex(viewableItems[0].index || 0)
},
[]
)
return (
<SafeAreaProvider>
<SafeAreaProvider style={{ backgroundColor: 'black' }}>
<StatusBar hidden />
<ImageViewer
images={imageUrls}
imageIndex={initialIndex}
onImageIndexChange={index => setCurrentIndex(index)}
onRequestClose={() => navigation.goBack()}
onLongPress={() => {
<View
style={{
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: insets.top,
position: 'absolute',
width: '100%',
zIndex: 999
}}
>
<HeaderLeft content='X' native={false} background onPress={() => navigation.goBack()} />
<HeaderCenter inverted content={`${currentIndex + 1} / ${imageUrls.length}`} />
<HeaderRight
accessibilityLabel={t('content.actions.accessibilityLabel')}
accessibilityHint={t('content.actions.accessibilityHint')}
content='MoreHorizontal'
native={false}
background
onPress={onPress}
/>
</View>
<LongPressGestureHandler
onEnded={() => {
analytics('imageviewer_more_press')
showActionSheetWithOptions(
{
@ -118,37 +201,26 @@ const ScreenImagesViewer = ({
}
)
}}
HeaderComponent={() => (
<View
style={{
flex: 1,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: insets.top
}}
>
<HeaderLeft
content='X'
native={false}
background
onPress={() => navigation.goBack()}
/>
<HeaderCenter
inverted
content={`${currentIndex + 1} / ${imageUrls.length}`}
/>
<HeaderRight
accessibilityLabel={t('content.actions.accessibilityLabel')}
accessibilityHint={t('content.actions.accessibilityHint')}
content='MoreHorizontal'
native={false}
background
onPress={onPress}
/>
</View>
)}
/>
>
<ZoomFlatList
ref={listRef}
data={imageUrls}
pagingEnabled
horizontal
keyExtractor={item => item.id}
renderItem={renderItem}
onViewableItemsChanged={onViewableItemsChanged}
viewabilityConfig={{
itemVisiblePercentThreshold: 50
}}
initialScrollIndex={initialIndex}
getItemLayout={(_, index) => ({
length: SCREEN_WIDTH,
offset: SCREEN_WIDTH * index,
index
})}
/>
</LongPressGestureHandler>
<Message ref={messageRef} />
</SafeAreaProvider>
)

View File

@ -7081,6 +7081,11 @@ react-native-pager-view@^5.4.25:
resolved "https://registry.yarnpkg.com/react-native-pager-view/-/react-native-pager-view-5.4.25.tgz#cd639d5387a7f3d5581b55a33c5faa1cbc200f97"
integrity sha512-3drrYwaLat2fYszymZe3nHMPASJ4aJMaxiejfA1V5SK3OygYmdtmV2u5prX7TnjueJzGSyyaCYEr2JlrRt4YPg==
react-native-reanimated-zoom@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/react-native-reanimated-zoom/-/react-native-reanimated-zoom-0.3.0.tgz#181825c1854853a4db33a86e521057e2e757d290"
integrity sha512-kvYVbLayX8Tj52oDmKE78gnEmZD5KsCHxkTSrMfahq9KyqU6aHWistfocFtzBBT+I0puzcHpivzy3dxYL1SL5Q==
react-native-reanimated@^2.9.1:
version "2.9.1"
resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-2.9.1.tgz#d9a932e312c13c05b4f919e43ebbf76d996e0bc1"