mirror of
https://github.com/tooot-app/app
synced 2025-06-05 22:19:13 +02:00
Move image viewer to a new plugin
This commit is contained in:
@ -86,6 +86,7 @@
|
|||||||
"react-native-language-detection": "^0.1.0",
|
"react-native-language-detection": "^0.1.0",
|
||||||
"react-native-pager-view": "^5.4.25",
|
"react-native-pager-view": "^5.4.25",
|
||||||
"react-native-reanimated": "^2.9.1",
|
"react-native-reanimated": "^2.9.1",
|
||||||
|
"react-native-reanimated-zoom": "^0.3.0",
|
||||||
"react-native-safe-area-context": "^4.3.1",
|
"react-native-safe-area-context": "^4.3.1",
|
||||||
"react-native-screens": "^3.16.0",
|
"react-native-screens": "^3.16.0",
|
||||||
"react-native-share-menu": "^6.0.0",
|
"react-native-share-menu": "^6.0.0",
|
||||||
|
@ -67,9 +67,7 @@ const GracefullyImage = ({
|
|||||||
const onLoad = () => {
|
const onLoad = () => {
|
||||||
setImageLoaded(true)
|
setImageLoaded(true)
|
||||||
if (setImageDimensions && source.uri) {
|
if (setImageDimensions && source.uri) {
|
||||||
Image.getSize(source.uri, (width, height) =>
|
Image.getSize(source.uri, (width, height) => setImageDimensions({ width, height }))
|
||||||
setImageDimensions({ width, height })
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const onError = () => {
|
const onError = () => {
|
||||||
@ -81,22 +79,9 @@ const GracefullyImage = ({
|
|||||||
const blurhashView = useMemo(() => {
|
const blurhashView = useMemo(() => {
|
||||||
if (hidden || !imageLoaded) {
|
if (hidden || !imageLoaded) {
|
||||||
if (blurhash) {
|
if (blurhash) {
|
||||||
return (
|
return <Blurhash decodeAsync blurhash={blurhash} style={styles.placeholder} />
|
||||||
<Blurhash
|
|
||||||
decodeAsync
|
|
||||||
blurhash={blurhash}
|
|
||||||
style={styles.placeholder}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
return (
|
return <View style={[styles.placeholder, { backgroundColor: colors.shimmerDefault }]} />
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.placeholder,
|
|
||||||
{ backgroundColor: colors.shimmerDefault }
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return null
|
return null
|
||||||
@ -105,26 +90,17 @@ const GracefullyImage = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
{...(onPress
|
{...(onPress ? { accessibilityRole: 'imagebutton' } : { accessibilityRole: 'image' })}
|
||||||
? { accessibilityRole: 'imagebutton' }
|
|
||||||
: { accessibilityRole: 'image' })}
|
|
||||||
accessibilityLabel={accessibilityLabel}
|
accessibilityLabel={accessibilityLabel}
|
||||||
accessibilityHint={accessibilityHint}
|
accessibilityHint={accessibilityHint}
|
||||||
style={[style, dimension, { backgroundColor: colors.shimmerDefault }]}
|
style={[style, dimension, { backgroundColor: colors.shimmerDefault }]}
|
||||||
{...(onPress
|
{...(onPress ? (hidden ? { disabled: true } : { onPress }) : { disabled: true })}
|
||||||
? hidden
|
|
||||||
? { disabled: true }
|
|
||||||
: { onPress }
|
|
||||||
: { disabled: true })}
|
|
||||||
>
|
>
|
||||||
{uri.preview && !imageLoaded ? (
|
{uri.preview && !imageLoaded ? (
|
||||||
<Image
|
<Image
|
||||||
fadeDuration={0}
|
fadeDuration={0}
|
||||||
source={{ uri: uri.preview }}
|
source={{ uri: uri.preview }}
|
||||||
style={[
|
style={[styles.placeholder, { backgroundColor: colors.shimmerDefault }]}
|
||||||
styles.placeholder,
|
|
||||||
{ backgroundColor: colors.shimmerDefault }
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{Platform.OS === 'ios' ? (
|
{Platform.OS === 'ios' ? (
|
||||||
|
@ -1,7 +0,0 @@
|
|||||||
import * as rn from "react-native";
|
|
||||||
|
|
||||||
declare module "react-native" {
|
|
||||||
class VirtualizedList<ItemT> extends React.Component<
|
|
||||||
VirtualizedListProps<ItemT>
|
|
||||||
> {}
|
|
||||||
}
|
|
@ -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
|
|
||||||
}
|
|
@ -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
|
|
@ -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)
|
|
@ -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;
|
|
@ -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)
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,4 +1,5 @@
|
|||||||
import analytics from '@components/analytics'
|
import analytics from '@components/analytics'
|
||||||
|
import GracefullyImage from '@components/GracefullyImage'
|
||||||
import { HeaderCenter, HeaderLeft, HeaderRight } from '@components/Header'
|
import { HeaderCenter, HeaderLeft, HeaderRight } from '@components/Header'
|
||||||
import { Message } from '@components/Message'
|
import { Message } from '@components/Message'
|
||||||
import { useActionSheet } from '@expo/react-native-action-sheet'
|
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 { useTheme } from '@utils/styles/ThemeManager'
|
||||||
import React, { useCallback, useRef, useState } from 'react'
|
import React, { useCallback, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Platform, Share, StatusBar, View } from 'react-native'
|
|
||||||
import FlashMessage from 'react-native-flash-message'
|
|
||||||
import {
|
import {
|
||||||
SafeAreaProvider,
|
Dimensions,
|
||||||
useSafeAreaInsets
|
FlatList,
|
||||||
} from 'react-native-safe-area-context'
|
PixelRatio,
|
||||||
import ImageViewer from './ImageViewer/Root'
|
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'
|
import saveImage from './ImageViewer/save'
|
||||||
|
|
||||||
|
const ZoomFlatList = createZoomListComponent(FlatList)
|
||||||
|
|
||||||
const ScreenImagesViewer = ({
|
const ScreenImagesViewer = ({
|
||||||
route: {
|
route: {
|
||||||
params: { imageUrls, id }
|
params: { imageUrls, id }
|
||||||
@ -26,6 +36,9 @@ const ScreenImagesViewer = ({
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SCREEN_WIDTH = Dimensions.get('screen').width
|
||||||
|
const SCREEN_HEIGHT = Dimensions.get('screen').height
|
||||||
|
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
|
|
||||||
const { mode, theme } = useTheme()
|
const { mode, theme } = useTheme()
|
||||||
@ -34,6 +47,7 @@ const ScreenImagesViewer = ({
|
|||||||
const initialIndex = imageUrls.findIndex(image => image.id === id)
|
const initialIndex = imageUrls.findIndex(image => image.id === id)
|
||||||
const [currentIndex, setCurrentIndex] = useState(initialIndex)
|
const [currentIndex, setCurrentIndex] = useState(initialIndex)
|
||||||
|
|
||||||
|
const listRef = useRef<FlatList>(null)
|
||||||
const messageRef = useRef<FlashMessage>(null)
|
const messageRef = useRef<FlashMessage>(null)
|
||||||
|
|
||||||
const { showActionSheetWithOptions } = useActionSheet()
|
const { showActionSheetWithOptions } = useActionSheet()
|
||||||
@ -71,15 +85,84 @@ const ScreenImagesViewer = ({
|
|||||||
)
|
)
|
||||||
}, [currentIndex])
|
}, [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 (
|
return (
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider style={{ backgroundColor: 'black' }}>
|
||||||
<StatusBar hidden />
|
<StatusBar hidden />
|
||||||
<ImageViewer
|
<View
|
||||||
images={imageUrls}
|
style={{
|
||||||
imageIndex={initialIndex}
|
flexDirection: 'row',
|
||||||
onImageIndexChange={index => setCurrentIndex(index)}
|
justifyContent: 'space-between',
|
||||||
onRequestClose={() => navigation.goBack()}
|
alignItems: 'center',
|
||||||
onLongPress={() => {
|
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')
|
analytics('imageviewer_more_press')
|
||||||
showActionSheetWithOptions(
|
showActionSheetWithOptions(
|
||||||
{
|
{
|
||||||
@ -118,37 +201,26 @@ const ScreenImagesViewer = ({
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
HeaderComponent={() => (
|
>
|
||||||
<View
|
<ZoomFlatList
|
||||||
style={{
|
ref={listRef}
|
||||||
flex: 1,
|
data={imageUrls}
|
||||||
flexDirection: 'row',
|
pagingEnabled
|
||||||
justifyContent: 'space-between',
|
horizontal
|
||||||
alignItems: 'center',
|
keyExtractor={item => item.id}
|
||||||
marginTop: insets.top
|
renderItem={renderItem}
|
||||||
}}
|
onViewableItemsChanged={onViewableItemsChanged}
|
||||||
>
|
viewabilityConfig={{
|
||||||
<HeaderLeft
|
itemVisiblePercentThreshold: 50
|
||||||
content='X'
|
}}
|
||||||
native={false}
|
initialScrollIndex={initialIndex}
|
||||||
background
|
getItemLayout={(_, index) => ({
|
||||||
onPress={() => navigation.goBack()}
|
length: SCREEN_WIDTH,
|
||||||
/>
|
offset: SCREEN_WIDTH * index,
|
||||||
<HeaderCenter
|
index
|
||||||
inverted
|
})}
|
||||||
content={`${currentIndex + 1} / ${imageUrls.length}`}
|
/>
|
||||||
/>
|
</LongPressGestureHandler>
|
||||||
<HeaderRight
|
|
||||||
accessibilityLabel={t('content.actions.accessibilityLabel')}
|
|
||||||
accessibilityHint={t('content.actions.accessibilityHint')}
|
|
||||||
content='MoreHorizontal'
|
|
||||||
native={false}
|
|
||||||
background
|
|
||||||
onPress={onPress}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Message ref={messageRef} />
|
<Message ref={messageRef} />
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
)
|
)
|
||||||
|
@ -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"
|
resolved "https://registry.yarnpkg.com/react-native-pager-view/-/react-native-pager-view-5.4.25.tgz#cd639d5387a7f3d5581b55a33c5faa1cbc200f97"
|
||||||
integrity sha512-3drrYwaLat2fYszymZe3nHMPASJ4aJMaxiejfA1V5SK3OygYmdtmV2u5prX7TnjueJzGSyyaCYEr2JlrRt4YPg==
|
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:
|
react-native-reanimated@^2.9.1:
|
||||||
version "2.9.1"
|
version "2.9.1"
|
||||||
resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-2.9.1.tgz#d9a932e312c13c05b4f919e43ebbf76d996e0bc1"
|
resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-2.9.1.tgz#d9a932e312c13c05b4f919e43ebbf76d996e0bc1"
|
||||||
|
Reference in New Issue
Block a user