Saves image on long press (#83)
* Saves image on long press * Adds save on long press * Forking lightbox * move to wrapper only to the bottom sheet to reduce impact of this change * lint * lint * lint * Use official `share` API * Clean up cache after download * comment * comment * Reduce swipe close velocity * Updates per feedback * lint * bugfix * Adds delayed press-in for TouchableOpacityzio/stable
parent
adf328b50c
commit
eb33c3fa81
|
@ -238,7 +238,7 @@ PODS:
|
||||||
- glog
|
- glog
|
||||||
- react-native-blur (4.3.0):
|
- react-native-blur (4.3.0):
|
||||||
- React-Core
|
- React-Core
|
||||||
- react-native-cameraroll (5.2.0):
|
- react-native-cameraroll (5.2.2):
|
||||||
- React-Core
|
- React-Core
|
||||||
- react-native-image-resizer (3.0.4):
|
- react-native-image-resizer (3.0.4):
|
||||||
- React-Core
|
- React-Core
|
||||||
|
@ -597,13 +597,13 @@ EXTERNAL SOURCES:
|
||||||
:path: "../node_modules/react-native/ReactCommon/yoga"
|
:path: "../node_modules/react-native/ReactCommon/yoga"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
boost: a7c83b31436843459a1961bfd74b96033dc77234
|
boost: 57d2868c099736d80fcd648bf211b4431e51a558
|
||||||
BVLinearGradient: 34a999fda29036898a09c6a6b728b0b4189e1a44
|
BVLinearGradient: 34a999fda29036898a09c6a6b728b0b4189e1a44
|
||||||
DoubleConversion: 831926d9b8bf8166fd87886c4abab286c2422662
|
DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54
|
||||||
FBLazyVector: 61839cba7a48c570b7ac3e1cd8a4d0948382202f
|
FBLazyVector: 61839cba7a48c570b7ac3e1cd8a4d0948382202f
|
||||||
FBReactNativeSpec: 5a14398ccf5e27c1ca2d7109eb920594ce93c10d
|
FBReactNativeSpec: 5a14398ccf5e27c1ca2d7109eb920594ce93c10d
|
||||||
fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9
|
fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9
|
||||||
glog: 476ee3e89abb49e07f822b48323c51c57124b572
|
glog: 04b94705f318337d7ead9e6d17c019bd9b1f6b1b
|
||||||
hermes-engine: f6e715aa6c8bd38de6c13bc85e07b0a337edaa89
|
hermes-engine: f6e715aa6c8bd38de6c13bc85e07b0a337edaa89
|
||||||
libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913
|
libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913
|
||||||
RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1
|
RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1
|
||||||
|
@ -621,7 +621,7 @@ SPEC CHECKSUMS:
|
||||||
React-jsinspector: 5061fcbec93fd672183dfb39cc2f65e55a0835db
|
React-jsinspector: 5061fcbec93fd672183dfb39cc2f65e55a0835db
|
||||||
React-logger: a6c0b3a807a8e81f6d7fea2e72660766f55daa50
|
React-logger: a6c0b3a807a8e81f6d7fea2e72660766f55daa50
|
||||||
react-native-blur: 50c9feabacbc5f49b61337ebc32192c6be7ec3c3
|
react-native-blur: 50c9feabacbc5f49b61337ebc32192c6be7ec3c3
|
||||||
react-native-cameraroll: 0ff04cc4e0ff5f19a94ff4313e5c8bc4503cd86d
|
react-native-cameraroll: 71d68167beb6fc7216aa564abb6d86f1d666a2c6
|
||||||
react-native-image-resizer: 794abf75ec13ed1f0dbb1f134e27504ea65e9e66
|
react-native-image-resizer: 794abf75ec13ed1f0dbb1f134e27504ea65e9e66
|
||||||
react-native-pager-view: 54bed894cecebe28cede54c01038d9d1e122de43
|
react-native-pager-view: 54bed894cecebe28cede54c01038d9d1e122de43
|
||||||
react-native-paste-input: 5182843692fd2ec72be50f241a38a49796e225d7
|
react-native-paste-input: 5182843692fd2ec72be50f241a38a49796e225d7
|
||||||
|
|
|
@ -27,7 +27,7 @@
|
||||||
"@mattermost/react-native-paste-input": "^0.6.0",
|
"@mattermost/react-native-paste-input": "^0.6.0",
|
||||||
"@notifee/react-native": "^7.4.0",
|
"@notifee/react-native": "^7.4.0",
|
||||||
"@react-native-async-storage/async-storage": "^1.17.6",
|
"@react-native-async-storage/async-storage": "^1.17.6",
|
||||||
"@react-native-camera-roll/camera-roll": "^5.1.0",
|
"@react-native-camera-roll/camera-roll": "^5.2.2",
|
||||||
"@react-native-clipboard/clipboard": "^1.10.0",
|
"@react-native-clipboard/clipboard": "^1.10.0",
|
||||||
"@react-native-community/blur": "^4.3.0",
|
"@react-native-community/blur": "^4.3.0",
|
||||||
"@segment/analytics-react-native": "^2.10.1",
|
"@segment/analytics-react-native": "^2.10.1",
|
||||||
|
@ -51,7 +51,6 @@
|
||||||
"react-native-gesture-handler": "^2.5.0",
|
"react-native-gesture-handler": "^2.5.0",
|
||||||
"react-native-haptic-feedback": "^1.14.0",
|
"react-native-haptic-feedback": "^1.14.0",
|
||||||
"react-native-image-crop-picker": "^0.38.1",
|
"react-native-image-crop-picker": "^0.38.1",
|
||||||
"react-native-image-viewing": "^0.2.2",
|
|
||||||
"react-native-inappbrowser-reborn": "^3.6.3",
|
"react-native-inappbrowser-reborn": "^3.6.3",
|
||||||
"react-native-linear-gradient": "^2.6.2",
|
"react-native-linear-gradient": "^2.6.2",
|
||||||
"react-native-pager-view": "^6.0.2",
|
"react-native-pager-view": "^6.0.2",
|
||||||
|
|
|
@ -1,5 +1,9 @@
|
||||||
import RNFetchBlob from 'rn-fetch-blob'
|
import RNFetchBlob from 'rn-fetch-blob'
|
||||||
import ImageResizer from '@bam.tech/react-native-image-resizer'
|
import ImageResizer from '@bam.tech/react-native-image-resizer'
|
||||||
|
import {Share} from 'react-native'
|
||||||
|
import RNFS from 'react-native-fs'
|
||||||
|
|
||||||
|
import * as Toast from '../view/com/util/Toast'
|
||||||
|
|
||||||
export interface DownloadAndResizeOpts {
|
export interface DownloadAndResizeOpts {
|
||||||
uri: string
|
uri: string
|
||||||
|
@ -128,3 +132,21 @@ export function scaleDownDimensions(dim: Dim, max: Dim): Dim {
|
||||||
}
|
}
|
||||||
return {width: dim.width * hScale, height: dim.height * hScale}
|
return {width: dim.width * hScale, height: dim.height * hScale}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const saveImageModal = async ({uri}: {uri: string}) => {
|
||||||
|
const downloadResponse = await RNFetchBlob.config({
|
||||||
|
fileCache: true,
|
||||||
|
}).fetch('GET', uri)
|
||||||
|
|
||||||
|
const imagePath = downloadResponse.path()
|
||||||
|
const base64Data = await downloadResponse.readFile('base64')
|
||||||
|
const result = await Share.share({
|
||||||
|
url: 'data:image/png;base64,' + base64Data,
|
||||||
|
})
|
||||||
|
if (result.action === Share.sharedAction) {
|
||||||
|
Toast.show('Image saved to gallery')
|
||||||
|
} else if (result.action === Share.dismissedAction) {
|
||||||
|
// dismissed
|
||||||
|
}
|
||||||
|
RNFS.unlink(imagePath)
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
/**
|
||||||
|
* 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 {ImageURISource, ImageRequireSource} from 'react-native'
|
||||||
|
|
||||||
|
export type Dimensions = {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Position = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImageSource = ImageURISource | ImageRequireSource
|
|
@ -0,0 +1,52 @@
|
||||||
|
/**
|
||||||
|
* 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 {SafeAreaView, Text, TouchableOpacity, StyleSheet} from 'react-native'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onRequestClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const HIT_SLOP = {top: 16, left: 16, bottom: 16, right: 16}
|
||||||
|
|
||||||
|
const ImageDefaultHeader = ({onRequestClose}: Props) => (
|
||||||
|
<SafeAreaView style={styles.root}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.closeButton}
|
||||||
|
onPress={onRequestClose}
|
||||||
|
hitSlop={HIT_SLOP}>
|
||||||
|
<Text style={styles.closeText}>✕</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</SafeAreaView>
|
||||||
|
)
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: {
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
marginRight: 8,
|
||||||
|
marginTop: 8,
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: 22,
|
||||||
|
backgroundColor: '#00000077',
|
||||||
|
},
|
||||||
|
closeText: {
|
||||||
|
lineHeight: 22,
|
||||||
|
fontSize: 19,
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#FFF',
|
||||||
|
includeFontPadding: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export default ImageDefaultHeader
|
|
@ -0,0 +1,152 @@
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
Animated,
|
||||||
|
ScrollView,
|
||||||
|
Dimensions,
|
||||||
|
StyleSheet,
|
||||||
|
NativeScrollEvent,
|
||||||
|
NativeSyntheticEvent,
|
||||||
|
NativeMethodsMixin,
|
||||||
|
} from 'react-native'
|
||||||
|
|
||||||
|
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
|
||||||
|
onRequestClose: () => void
|
||||||
|
onZoom: (isZoomed: boolean) => void
|
||||||
|
onLongPress: (image: ImageSource) => void
|
||||||
|
delayLongPress: number
|
||||||
|
swipeToCloseEnabled?: boolean
|
||||||
|
doubleTapToZoomEnabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImageItem = ({
|
||||||
|
imageSrc,
|
||||||
|
onZoom,
|
||||||
|
onRequestClose,
|
||||||
|
onLongPress,
|
||||||
|
delayLongPress,
|
||||||
|
swipeToCloseEnabled = true,
|
||||||
|
doubleTapToZoomEnabled = true,
|
||||||
|
}: Props) => {
|
||||||
|
const imageContainer = useRef<ScrollView & NativeMethodsMixin>(null)
|
||||||
|
const imageDimensions = useImageDimensions(imageSrc)
|
||||||
|
const [translate, scale] = getImageTransform(imageDimensions, SCREEN)
|
||||||
|
const scrollValueY = new Animated.Value(0)
|
||||||
|
const [isLoaded, setLoadEnd] = useState(false)
|
||||||
|
|
||||||
|
const 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 imagesStyles = getImageStyles(
|
||||||
|
imageDimensions,
|
||||||
|
translateValue,
|
||||||
|
scaleValue,
|
||||||
|
)
|
||||||
|
const imageOpacity = scrollValueY.interpolate({
|
||||||
|
inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
|
||||||
|
outputRange: [0.7, 1, 0.7],
|
||||||
|
})
|
||||||
|
const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity}
|
||||||
|
|
||||||
|
const onScrollEndDrag = ({
|
||||||
|
nativeEvent,
|
||||||
|
}: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||||
|
const velocityY = nativeEvent?.velocity?.y ?? 0
|
||||||
|
const offsetY = nativeEvent?.contentOffset?.y ?? 0
|
||||||
|
|
||||||
|
if (
|
||||||
|
(Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY &&
|
||||||
|
offsetY > SWIPE_CLOSE_OFFSET) ||
|
||||||
|
offsetY > SCREEN_HEIGHT / 2
|
||||||
|
) {
|
||||||
|
onRequestClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onScroll = ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||||
|
const offsetY = nativeEvent?.contentOffset?.y ?? 0
|
||||||
|
|
||||||
|
scrollValueY.setValue(offsetY)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
ref={imageContainer}
|
||||||
|
style={styles.listItem}
|
||||||
|
pagingEnabled
|
||||||
|
nestedScrollEnabled
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.imageScrollContainer}
|
||||||
|
scrollEnabled={swipeToCloseEnabled}
|
||||||
|
{...(swipeToCloseEnabled && {
|
||||||
|
onScroll,
|
||||||
|
onScrollEndDrag,
|
||||||
|
})}>
|
||||||
|
<Animated.Image
|
||||||
|
{...panHandlers}
|
||||||
|
source={imageSrc}
|
||||||
|
style={imageStylesWithOpacity}
|
||||||
|
onLoad={onLoaded}
|
||||||
|
/>
|
||||||
|
{(!isLoaded || !imageDimensions) && <ImageLoading />}
|
||||||
|
</ScrollView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
listItem: {
|
||||||
|
width: SCREEN_WIDTH,
|
||||||
|
height: SCREEN_HEIGHT,
|
||||||
|
},
|
||||||
|
imageScrollContainer: {
|
||||||
|
height: SCREEN_HEIGHT * 2,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export default React.memo(ImageItem)
|
|
@ -0,0 +1,152 @@
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
Animated,
|
||||||
|
Dimensions,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
NativeScrollEvent,
|
||||||
|
NativeSyntheticEvent,
|
||||||
|
TouchableWithoutFeedback,
|
||||||
|
} from 'react-native'
|
||||||
|
|
||||||
|
import useDoubleTapToZoom from '../../hooks/useDoubleTapToZoom'
|
||||||
|
import useImageDimensions from '../../hooks/useImageDimensions'
|
||||||
|
|
||||||
|
import {getImageStyles, getImageTransform} from '../../utils'
|
||||||
|
import {ImageSource} from '../../@types'
|
||||||
|
import {ImageLoading} from './ImageLoading'
|
||||||
|
|
||||||
|
const SWIPE_CLOSE_OFFSET = 75
|
||||||
|
const SWIPE_CLOSE_VELOCITY = 1
|
||||||
|
const SCREEN = Dimensions.get('screen')
|
||||||
|
const SCREEN_WIDTH = SCREEN.width
|
||||||
|
const SCREEN_HEIGHT = SCREEN.height
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
imageSrc: ImageSource
|
||||||
|
onRequestClose: () => void
|
||||||
|
onZoom: (scaled: boolean) => void
|
||||||
|
onLongPress: (image: ImageSource) => void
|
||||||
|
delayLongPress: number
|
||||||
|
swipeToCloseEnabled?: boolean
|
||||||
|
doubleTapToZoomEnabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImageItem = ({
|
||||||
|
imageSrc,
|
||||||
|
onZoom,
|
||||||
|
onRequestClose,
|
||||||
|
onLongPress,
|
||||||
|
delayLongPress,
|
||||||
|
swipeToCloseEnabled = true,
|
||||||
|
doubleTapToZoomEnabled = true,
|
||||||
|
}: Props) => {
|
||||||
|
const scrollViewRef = useRef<ScrollView>(null)
|
||||||
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
const [scaled, setScaled] = useState(false)
|
||||||
|
const imageDimensions = useImageDimensions(imageSrc)
|
||||||
|
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 currentScaled = nativeEvent?.zoomScale > 1
|
||||||
|
|
||||||
|
onZoom(currentScaled)
|
||||||
|
setScaled(currentScaled)
|
||||||
|
|
||||||
|
if (
|
||||||
|
!currentScaled &&
|
||||||
|
swipeToCloseEnabled &&
|
||||||
|
Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY
|
||||||
|
) {
|
||||||
|
onRequestClose()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onRequestClose, onZoom, swipeToCloseEnabled],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onScroll = ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||||
|
const offsetY = nativeEvent?.contentOffset?.y ?? 0
|
||||||
|
|
||||||
|
if (nativeEvent?.zoomScale > 1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollValueY.setValue(offsetY)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onLongPressHandler = useCallback(() => {
|
||||||
|
onLongPress(imageSrc)
|
||||||
|
}, [imageSrc, onLongPress])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<ScrollView
|
||||||
|
ref={scrollViewRef}
|
||||||
|
style={styles.listItem}
|
||||||
|
pinchGestureEnabled
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
maximumZoomScale={maxScale}
|
||||||
|
contentContainerStyle={styles.imageScrollContainer}
|
||||||
|
scrollEnabled={swipeToCloseEnabled}
|
||||||
|
onScrollEndDrag={onScrollEndDrag}
|
||||||
|
scrollEventThrottle={1}
|
||||||
|
{...(swipeToCloseEnabled && {
|
||||||
|
onScroll,
|
||||||
|
})}>
|
||||||
|
{(!loaded || !imageDimensions) && <ImageLoading />}
|
||||||
|
<TouchableWithoutFeedback
|
||||||
|
onPress={doubleTapToZoomEnabled ? handleDoubleTap : undefined}
|
||||||
|
onLongPress={onLongPressHandler}
|
||||||
|
delayLongPress={delayLongPress}>
|
||||||
|
<Animated.Image
|
||||||
|
source={imageSrc}
|
||||||
|
style={imageStylesWithOpacity}
|
||||||
|
onLoad={() => setLoaded(true)}
|
||||||
|
/>
|
||||||
|
</TouchableWithoutFeedback>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
listItem: {
|
||||||
|
width: SCREEN_WIDTH,
|
||||||
|
height: SCREEN_HEIGHT,
|
||||||
|
},
|
||||||
|
imageScrollContainer: {
|
||||||
|
height: SCREEN_HEIGHT,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export default React.memo(ImageItem)
|
|
@ -0,0 +1,37 @@
|
||||||
|
/**
|
||||||
|
* 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 {ActivityIndicator, Dimensions, StyleSheet, View} from 'react-native'
|
||||||
|
|
||||||
|
const SCREEN = Dimensions.get('screen')
|
||||||
|
const SCREEN_WIDTH = SCREEN.width
|
||||||
|
const SCREEN_HEIGHT = SCREEN.height
|
||||||
|
|
||||||
|
export const ImageLoading = () => (
|
||||||
|
<View style={styles.loading}>
|
||||||
|
<ActivityIndicator size="small" color="#FFF" />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
listItem: {
|
||||||
|
width: SCREEN_WIDTH,
|
||||||
|
height: SCREEN_HEIGHT,
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
width: SCREEN_WIDTH,
|
||||||
|
height: SCREEN_HEIGHT,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
imageScrollContainer: {
|
||||||
|
height: SCREEN_HEIGHT,
|
||||||
|
},
|
||||||
|
})
|
|
@ -0,0 +1,47 @@
|
||||||
|
/**
|
||||||
|
* 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 footerTranslate = new Animated.ValueXY(INITIAL_POSITION)
|
||||||
|
|
||||||
|
const toggleVisible = (isVisible: boolean) => {
|
||||||
|
if (isVisible) {
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(headerTranslate.y, {...ANIMATION_CONFIG, toValue: 0}),
|
||||||
|
Animated.timing(footerTranslate.y, {...ANIMATION_CONFIG, toValue: 0}),
|
||||||
|
]).start()
|
||||||
|
} else {
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(headerTranslate.y, {
|
||||||
|
...ANIMATION_CONFIG,
|
||||||
|
toValue: -300,
|
||||||
|
}),
|
||||||
|
Animated.timing(footerTranslate.y, {
|
||||||
|
...ANIMATION_CONFIG,
|
||||||
|
toValue: 300,
|
||||||
|
}),
|
||||||
|
]).start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerTransform = headerTranslate.getTranslateTransform()
|
||||||
|
const footerTransform = footerTranslate.getTranslateTransform()
|
||||||
|
|
||||||
|
return [headerTransform, footerTransform, toggleVisible] as const
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useAnimatedComponents
|
|
@ -0,0 +1,65 @@
|
||||||
|
/**
|
||||||
|
* 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, NativeTouchEvent, NativeSyntheticEvent} from 'react-native'
|
||||||
|
|
||||||
|
import {Dimensions} from '../@types'
|
||||||
|
|
||||||
|
const DOUBLE_TAP_DELAY = 300
|
||||||
|
let lastTapTS: number | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(
|
||||||
|
(event: NativeSyntheticEvent<NativeTouchEvent>) => {
|
||||||
|
const nowTS = new Date().getTime()
|
||||||
|
const scrollResponderRef = scrollViewRef?.current?.getScrollResponder()
|
||||||
|
|
||||||
|
if (lastTapTS && nowTS - lastTapTS < DOUBLE_TAP_DELAY) {
|
||||||
|
const {pageX, pageY} = event.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 = pageX / 2
|
||||||
|
targetY = pageY / 2
|
||||||
|
targetWidth = screen.width / 2
|
||||||
|
targetHeight = screen.height / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
scrollResponderRef?.scrollResponderZoomTo({
|
||||||
|
x: targetX,
|
||||||
|
y: targetY,
|
||||||
|
width: targetWidth,
|
||||||
|
height: targetHeight,
|
||||||
|
animated: true,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
lastTapTS = nowTS
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[scaled, screen.height, screen.width, scrollViewRef],
|
||||||
|
)
|
||||||
|
|
||||||
|
return handleDoubleTap
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useDoubleTapToZoom
|
|
@ -0,0 +1,88 @@
|
||||||
|
/**
|
||||||
|
* 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, useState} from 'react'
|
||||||
|
import {Image, ImageURISource} from 'react-native'
|
||||||
|
|
||||||
|
import {createCache} from '../utils'
|
||||||
|
import {Dimensions, ImageSource} from '../@types'
|
||||||
|
|
||||||
|
const CACHE_SIZE = 50
|
||||||
|
const imageDimensionsCache = createCache(CACHE_SIZE)
|
||||||
|
|
||||||
|
const useImageDimensions = (image: ImageSource): Dimensions | null => {
|
||||||
|
const [dimensions, setDimensions] = useState<Dimensions | null>(null)
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||||
|
const getImageDimensions = (image: ImageSource): Promise<Dimensions> => {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
if (typeof image === 'number') {
|
||||||
|
const cacheKey = `${image}`
|
||||||
|
let imageDimensions = imageDimensionsCache.get(cacheKey)
|
||||||
|
|
||||||
|
if (!imageDimensions) {
|
||||||
|
const {width, height} = Image.resolveAssetSource(image)
|
||||||
|
imageDimensions = {width, height}
|
||||||
|
imageDimensionsCache.set(cacheKey, imageDimensions)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(imageDimensions)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
if (image.uri) {
|
||||||
|
const source = image as ImageURISource
|
||||||
|
|
||||||
|
const cacheKey = source.uri as string
|
||||||
|
|
||||||
|
const imageDimensions = imageDimensionsCache.get(cacheKey)
|
||||||
|
|
||||||
|
if (imageDimensions) {
|
||||||
|
resolve(imageDimensions)
|
||||||
|
} else {
|
||||||
|
// @ts-ignore
|
||||||
|
Image.getSizeWithHeaders(
|
||||||
|
source.uri,
|
||||||
|
source.headers,
|
||||||
|
(width: number, height: number) => {
|
||||||
|
imageDimensionsCache.set(cacheKey, {width, height})
|
||||||
|
resolve({width, height})
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
resolve({width: 0, height: 0})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resolve({width: 0, height: 0})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let isImageUnmounted = false
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||||
|
getImageDimensions(image).then(dimensions => {
|
||||||
|
if (!isImageUnmounted) {
|
||||||
|
setDimensions(dimensions)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
isImageUnmounted = true
|
||||||
|
}
|
||||||
|
}, [image])
|
||||||
|
|
||||||
|
return dimensions
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useImageDimensions
|
|
@ -0,0 +1,32 @@
|
||||||
|
/**
|
||||||
|
* 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
|
|
@ -0,0 +1,25 @@
|
||||||
|
/**
|
||||||
|
* 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 {Image} from 'react-native'
|
||||||
|
import {ImageSource} from '../@types'
|
||||||
|
|
||||||
|
const useImagePrefetch = (images: ImageSource[]) => {
|
||||||
|
useEffect(() => {
|
||||||
|
images.forEach(image => {
|
||||||
|
//@ts-ignore
|
||||||
|
if (image.uri) {
|
||||||
|
//@ts-ignore
|
||||||
|
return Image.prefetch(image.uri)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [images])
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useImagePrefetch
|
|
@ -0,0 +1,400 @@
|
||||||
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
|
/**
|
||||||
|
* 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 = 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: 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
// Handle double tap event by calculating diff between first and second taps timestamps
|
||||||
|
|
||||||
|
isDoubleTapPerformed = Boolean(
|
||||||
|
lastTapTS && tapTS - lastTapTS < DOUBLE_TAP_DELAY,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (doubleTapToZoomEnabled && isDoubleTapPerformed) {
|
||||||
|
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
|
||||||
|
} else {
|
||||||
|
lastTapTS = Date.now()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onMove: (
|
||||||
|
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}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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
|
|
@ -0,0 +1,24 @@
|
||||||
|
/**
|
||||||
|
* 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()
|
||||||
|
setTimeout(() => setOpacity(1), 0)
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useRequestClose
|
|
@ -0,0 +1,183 @@
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
// Original code copied and simplified from the link below as the codebase is currently not maintained:
|
||||||
|
// https://github.com/jobtoday/react-native-image-viewing
|
||||||
|
|
||||||
|
import React, {ComponentType, useCallback, useRef, useEffect} from 'react'
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Dimensions,
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
VirtualizedList,
|
||||||
|
ModalProps,
|
||||||
|
} from 'react-native'
|
||||||
|
import {Modal} from '../../modals/Modal'
|
||||||
|
|
||||||
|
import ImageItem from './components/ImageItem/ImageItem'
|
||||||
|
import ImageDefaultHeader from './components/ImageDefaultHeader'
|
||||||
|
|
||||||
|
import useAnimatedComponents from './hooks/useAnimatedComponents'
|
||||||
|
import useImageIndexChange from './hooks/useImageIndexChange'
|
||||||
|
import useRequestClose from './hooks/useRequestClose'
|
||||||
|
import {ImageSource} from './@types'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
images: ImageSource[]
|
||||||
|
keyExtractor?: (imageSrc: ImageSource, index: number) => string
|
||||||
|
imageIndex: number
|
||||||
|
visible: boolean
|
||||||
|
onRequestClose: () => void
|
||||||
|
onLongPress?: (image: ImageSource) => void
|
||||||
|
onImageIndexChange?: (imageIndex: number) => void
|
||||||
|
presentationStyle?: ModalProps['presentationStyle']
|
||||||
|
animationType?: ModalProps['animationType']
|
||||||
|
backgroundColor?: string
|
||||||
|
swipeToCloseEnabled?: boolean
|
||||||
|
doubleTapToZoomEnabled?: boolean
|
||||||
|
delayLongPress?: number
|
||||||
|
HeaderComponent?: ComponentType<{imageIndex: number}>
|
||||||
|
FooterComponent?: 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 ImageViewing({
|
||||||
|
images,
|
||||||
|
keyExtractor,
|
||||||
|
imageIndex,
|
||||||
|
visible,
|
||||||
|
onRequestClose,
|
||||||
|
onLongPress = () => {},
|
||||||
|
onImageIndexChange,
|
||||||
|
backgroundColor = DEFAULT_BG_COLOR,
|
||||||
|
swipeToCloseEnabled,
|
||||||
|
doubleTapToZoomEnabled,
|
||||||
|
delayLongPress = DEFAULT_DELAY_LONG_PRESS,
|
||||||
|
HeaderComponent,
|
||||||
|
FooterComponent,
|
||||||
|
}: Props) {
|
||||||
|
const imageList = useRef<VirtualizedList<ImageSource>>(null)
|
||||||
|
const [opacity, onRequestCloseEnhanced] = useRequestClose(onRequestClose)
|
||||||
|
const [currentImageIndex, onScroll] = useImageIndexChange(imageIndex, SCREEN)
|
||||||
|
const [headerTransform, footerTransform, toggleBarsVisible] =
|
||||||
|
useAnimatedComponents()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (onImageIndexChange) {
|
||||||
|
onImageIndexChange(currentImageIndex)
|
||||||
|
}
|
||||||
|
}, [currentImageIndex, onImageIndexChange])
|
||||||
|
|
||||||
|
const onZoom = useCallback(
|
||||||
|
(isScaled: boolean) => {
|
||||||
|
// @ts-ignore
|
||||||
|
imageList?.current?.setNativeProps({scrollEnabled: !isScaled})
|
||||||
|
toggleBarsVisible(!isScaled)
|
||||||
|
},
|
||||||
|
[toggleBarsVisible],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!visible) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.screen}>
|
||||||
|
<Modal />
|
||||||
|
<View style={[styles.container, {opacity, backgroundColor}]}>
|
||||||
|
<Animated.View style={[styles.header, {transform: headerTransform}]}>
|
||||||
|
{typeof HeaderComponent !== 'undefined' ? (
|
||||||
|
React.createElement(HeaderComponent, {
|
||||||
|
imageIndex: currentImageIndex,
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<ImageDefaultHeader onRequestClose={onRequestCloseEnhanced} />
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
<VirtualizedList
|
||||||
|
ref={imageList}
|
||||||
|
data={images}
|
||||||
|
horizontal
|
||||||
|
pagingEnabled
|
||||||
|
windowSize={2}
|
||||||
|
initialNumToRender={1}
|
||||||
|
maxToRenderPerBatch={1}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
initialScrollIndex={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}
|
||||||
|
doubleTapToZoomEnabled={doubleTapToZoomEnabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
onMomentumScrollEnd={onScroll}
|
||||||
|
//@ts-ignore
|
||||||
|
keyExtractor={(imageSrc, index) =>
|
||||||
|
keyExtractor
|
||||||
|
? keyExtractor(imageSrc, index)
|
||||||
|
: typeof imageSrc === 'number'
|
||||||
|
? `${imageSrc}`
|
||||||
|
: imageSrc.uri
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{typeof FooterComponent !== 'undefined' && (
|
||||||
|
<Animated.View style={[styles.footer, {transform: footerTransform}]}>
|
||||||
|
{React.createElement(FooterComponent, {
|
||||||
|
imageIndex: currentImageIndex,
|
||||||
|
})}
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
screen: {
|
||||||
|
position: 'absolute',
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#000',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: '100%',
|
||||||
|
zIndex: 1,
|
||||||
|
top: 0,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: '100%',
|
||||||
|
zIndex: 1,
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const EnhancedImageViewing = (props: Props) => (
|
||||||
|
<ImageViewing key={props.imageIndex} {...props} />
|
||||||
|
)
|
||||||
|
|
||||||
|
export default EnhancedImageViewing
|
|
@ -0,0 +1,179 @@
|
||||||
|
/**
|
||||||
|
* 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'
|
||||||
|
|
||||||
|
type CacheStorageItem = {key: string; value: any}
|
||||||
|
|
||||||
|
export const createCache = (cacheSize: number) => ({
|
||||||
|
_storage: [] as CacheStorageItem[],
|
||||||
|
get(key: string): any {
|
||||||
|
const {value} =
|
||||||
|
this._storage.find(({key: storageKey}) => storageKey === key) || {}
|
||||||
|
|
||||||
|
return value
|
||||||
|
},
|
||||||
|
set(key: string, value: any) {
|
||||||
|
if (this._storage.length >= cacheSize) {
|
||||||
|
this._storage.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
this._storage.push({key, value})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const splitArrayIntoBatches = (arr: any[], batchSize: number): any[] =>
|
||||||
|
arr.reduce((result, item) => {
|
||||||
|
const batch = result.pop() || []
|
||||||
|
|
||||||
|
if (batch.length < batchSize) {
|
||||||
|
batch.push(item)
|
||||||
|
result.push(batch)
|
||||||
|
} else {
|
||||||
|
result.push(batch, [item])
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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,20 +1,22 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {observer} from 'mobx-react-lite'
|
import {observer} from 'mobx-react-lite'
|
||||||
import ImageView from 'react-native-image-viewing'
|
import ImageView from './ImageViewing'
|
||||||
import {useStores} from '../../../state'
|
import {useStores} from '../../../state'
|
||||||
|
|
||||||
import * as models from '../../../state/models/shell-ui'
|
import * as models from '../../../state/models/shell-ui'
|
||||||
|
import {saveImageModal} from '../../../lib/images'
|
||||||
|
|
||||||
export const Lightbox = observer(function Lightbox() {
|
export const Lightbox = observer(function Lightbox() {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const onClose = () => {
|
if (!store.shell.isLightboxActive) {
|
||||||
console.log('hit')
|
return null
|
||||||
store.shell.closeLightbox()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!store.shell.isLightboxActive) {
|
const onClose = () => {
|
||||||
return <View />
|
store.shell.closeLightbox()
|
||||||
|
}
|
||||||
|
const onLongPress = ({uri}: {uri: string}) => {
|
||||||
|
saveImageModal({uri})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (store.shell.activeLightbox?.name === 'profile-image') {
|
if (store.shell.activeLightbox?.name === 'profile-image') {
|
||||||
|
@ -35,6 +37,7 @@ export const Lightbox = observer(function Lightbox() {
|
||||||
imageIndex={opts.index}
|
imageIndex={opts.index}
|
||||||
visible
|
visible
|
||||||
onRequestClose={onClose}
|
onRequestClose={onClose}
|
||||||
|
onLongPress={onLongPress}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -10,6 +10,7 @@ import {ImagesLightbox} from '../../../state/models/shell-ui'
|
||||||
import {useStores} from '../../../state'
|
import {useStores} from '../../../state'
|
||||||
import {usePalette} from '../../lib/hooks/usePalette'
|
import {usePalette} from '../../lib/hooks/usePalette'
|
||||||
import {gradients} from '../../lib/styles'
|
import {gradients} from '../../lib/styles'
|
||||||
|
import {saveImageModal} from '../../../lib/images'
|
||||||
|
|
||||||
type Embed =
|
type Embed =
|
||||||
| AppBskyEmbedImages.Presented
|
| AppBskyEmbedImages.Presented
|
||||||
|
@ -31,6 +32,10 @@ export function PostEmbeds({
|
||||||
const openLightbox = (index: number) => {
|
const openLightbox = (index: number) => {
|
||||||
store.shell.openLightbox(new ImagesLightbox(uris, index))
|
store.shell.openLightbox(new ImagesLightbox(uris, index))
|
||||||
}
|
}
|
||||||
|
const onLongPress = (index: number) => {
|
||||||
|
saveImageModal({uri: uris[index]})
|
||||||
|
}
|
||||||
|
|
||||||
if (embed.images.length === 4) {
|
if (embed.images.length === 4) {
|
||||||
return (
|
return (
|
||||||
<View style={[styles.imagesContainer, style]}>
|
<View style={[styles.imagesContainer, style]}>
|
||||||
|
@ -38,6 +43,7 @@ export function PostEmbeds({
|
||||||
type="four"
|
type="four"
|
||||||
uris={embed.images.map(img => img.thumb)}
|
uris={embed.images.map(img => img.thumb)}
|
||||||
onPress={openLightbox}
|
onPress={openLightbox}
|
||||||
|
onLongPress={onLongPress}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
@ -48,6 +54,7 @@ export function PostEmbeds({
|
||||||
type="three"
|
type="three"
|
||||||
uris={embed.images.map(img => img.thumb)}
|
uris={embed.images.map(img => img.thumb)}
|
||||||
onPress={openLightbox}
|
onPress={openLightbox}
|
||||||
|
onLongPress={onLongPress}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
@ -58,6 +65,7 @@ export function PostEmbeds({
|
||||||
type="two"
|
type="two"
|
||||||
uris={embed.images.map(img => img.thumb)}
|
uris={embed.images.map(img => img.thumb)}
|
||||||
onPress={openLightbox}
|
onPress={openLightbox}
|
||||||
|
onLongPress={onLongPress}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
@ -67,6 +75,7 @@ export function PostEmbeds({
|
||||||
<AutoSizedImage
|
<AutoSizedImage
|
||||||
uri={embed.images[0].thumb}
|
uri={embed.images[0].thumb}
|
||||||
onPress={() => openLightbox(0)}
|
onPress={() => openLightbox(0)}
|
||||||
|
onLongPress={() => onLongPress(0)}
|
||||||
containerStyle={styles.singleImage}
|
containerStyle={styles.singleImage}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
|
@ -5,13 +5,14 @@ import {
|
||||||
LayoutChangeEvent,
|
LayoutChangeEvent,
|
||||||
StyleProp,
|
StyleProp,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
TouchableWithoutFeedback,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
ViewStyle,
|
ViewStyle,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import {Text} from '../text/Text'
|
import {Text} from '../text/Text'
|
||||||
import {useTheme} from '../../../lib/ThemeContext'
|
import {useTheme} from '../../../lib/ThemeContext'
|
||||||
import {usePalette} from '../../../lib/hooks/usePalette'
|
import {usePalette} from '../../../lib/hooks/usePalette'
|
||||||
|
import {DELAY_PRESS_IN} from './constants'
|
||||||
|
|
||||||
const MAX_HEIGHT = 300
|
const MAX_HEIGHT = 300
|
||||||
|
|
||||||
|
@ -23,6 +24,7 @@ interface Dim {
|
||||||
export function AutoSizedImage({
|
export function AutoSizedImage({
|
||||||
uri,
|
uri,
|
||||||
onPress,
|
onPress,
|
||||||
|
onLongPress,
|
||||||
style,
|
style,
|
||||||
containerStyle,
|
containerStyle,
|
||||||
}: {
|
}: {
|
||||||
|
@ -80,7 +82,10 @@ export function AutoSizedImage({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={style}>
|
<View style={style}>
|
||||||
<TouchableWithoutFeedback onPress={onPress}>
|
<TouchableOpacity
|
||||||
|
onPress={onPress}
|
||||||
|
onLongPress={onLongPress}
|
||||||
|
delayPressIn={DELAY_PRESS_IN}>
|
||||||
{error ? (
|
{error ? (
|
||||||
<View style={[styles.errorContainer, errPal.view, containerStyle]}>
|
<View style={[styles.errorContainer, errPal.view, containerStyle]}>
|
||||||
<Text style={errPal.text}>{error}</Text>
|
<Text style={errPal.text}>{error}</Text>
|
||||||
|
@ -99,7 +104,7 @@ export function AutoSizedImage({
|
||||||
onLayout={onLayout}
|
onLayout={onLayout}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TouchableWithoutFeedback>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,14 +5,15 @@ import {
|
||||||
LayoutChangeEvent,
|
LayoutChangeEvent,
|
||||||
StyleProp,
|
StyleProp,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
TouchableWithoutFeedback,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
ViewStyle,
|
ViewStyle,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
|
import {DELAY_PRESS_IN} from './constants'
|
||||||
|
|
||||||
interface Dim {
|
interface Dim {
|
||||||
width: number
|
width: number
|
||||||
height: number
|
height: numberPressIn
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ImageLayoutGridType = 'two' | 'three' | 'four'
|
export type ImageLayoutGridType = 'two' | 'three' | 'four'
|
||||||
|
@ -21,6 +22,7 @@ export function ImageLayoutGrid({
|
||||||
type,
|
type,
|
||||||
uris,
|
uris,
|
||||||
onPress,
|
onPress,
|
||||||
|
onLongPress,
|
||||||
style,
|
style,
|
||||||
}: {
|
}: {
|
||||||
type: ImageLayoutGridType
|
type: ImageLayoutGridType
|
||||||
|
@ -44,6 +46,7 @@ export function ImageLayoutGrid({
|
||||||
type={type}
|
type={type}
|
||||||
uris={uris}
|
uris={uris}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
|
onLongPress={onLongPress}
|
||||||
containerInfo={containerInfo}
|
containerInfo={containerInfo}
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
@ -55,6 +58,7 @@ function ImageLayoutGridInner({
|
||||||
type,
|
type,
|
||||||
uris,
|
uris,
|
||||||
onPress,
|
onPress,
|
||||||
|
onLongPress,
|
||||||
containerInfo,
|
containerInfo,
|
||||||
}: {
|
}: {
|
||||||
type: ImageLayoutGridType
|
type: ImageLayoutGridType
|
||||||
|
@ -84,31 +88,46 @@ function ImageLayoutGridInner({
|
||||||
if (type === 'two') {
|
if (type === 'two') {
|
||||||
return (
|
return (
|
||||||
<View style={styles.flexRow}>
|
<View style={styles.flexRow}>
|
||||||
<TouchableWithoutFeedback onPress={() => onPress?.(0)}>
|
<TouchableOpacity
|
||||||
|
delayPressIn={DELAY_PRESS_IN}
|
||||||
|
onPress={() => onPress?.(0)}
|
||||||
|
onLongPress={() => onLongPress(0)}>
|
||||||
<Image source={{uri: uris[0]}} style={size1} />
|
<Image source={{uri: uris[0]}} style={size1} />
|
||||||
</TouchableWithoutFeedback>
|
</TouchableOpacity>
|
||||||
<View style={styles.wSpace} />
|
<View style={styles.wSpace} />
|
||||||
<TouchableWithoutFeedback onPress={() => onPress?.(1)}>
|
<TouchableOpacity
|
||||||
|
delayPressIn={DELAY_PRESS_IN}
|
||||||
|
onPress={() => onPress?.(1)}
|
||||||
|
onLongPress={() => onLongPress(1)}>
|
||||||
<Image source={{uri: uris[1]}} style={size1} />
|
<Image source={{uri: uris[1]}} style={size1} />
|
||||||
</TouchableWithoutFeedback>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (type === 'three') {
|
if (type === 'three') {
|
||||||
return (
|
return (
|
||||||
<View style={styles.flexRow}>
|
<View style={styles.flexRow}>
|
||||||
<TouchableWithoutFeedback onPress={() => onPress?.(0)}>
|
<TouchableOpacity
|
||||||
|
delayPressIn={DELAY_PRESS_IN}
|
||||||
|
onPress={() => onPress?.(0)}
|
||||||
|
onLongPress={() => onLongPress(0)}>
|
||||||
<Image source={{uri: uris[0]}} style={size2} />
|
<Image source={{uri: uris[0]}} style={size2} />
|
||||||
</TouchableWithoutFeedback>
|
</TouchableOpacity>
|
||||||
<View style={styles.wSpace} />
|
<View style={styles.wSpace} />
|
||||||
<View>
|
<View>
|
||||||
<TouchableWithoutFeedback onPress={() => onPress?.(1)}>
|
<TouchableOpacity
|
||||||
|
delayPressIn={DELAY_PRESS_IN}
|
||||||
|
onPress={() => onPress?.(1)}
|
||||||
|
onLongPress={() => onLongPress(1)}>
|
||||||
<Image source={{uri: uris[1]}} style={size1} />
|
<Image source={{uri: uris[1]}} style={size1} />
|
||||||
</TouchableWithoutFeedback>
|
</TouchableOpacity>
|
||||||
<View style={styles.hSpace} />
|
<View style={styles.hSpace} />
|
||||||
<TouchableWithoutFeedback onPress={() => onPress?.(2)}>
|
<TouchableOpacity
|
||||||
|
delayPressIn={DELAY_PRESS_IN}
|
||||||
|
onPress={() => onPress?.(2)}
|
||||||
|
onLongPress={() => onLongPress(2)}>
|
||||||
<Image source={{uri: uris[2]}} style={size1} />
|
<Image source={{uri: uris[2]}} style={size1} />
|
||||||
</TouchableWithoutFeedback>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
@ -117,23 +136,35 @@ function ImageLayoutGridInner({
|
||||||
return (
|
return (
|
||||||
<View style={styles.flexRow}>
|
<View style={styles.flexRow}>
|
||||||
<View>
|
<View>
|
||||||
<TouchableWithoutFeedback onPress={() => onPress?.(0)}>
|
<TouchableOpacity
|
||||||
|
delayPressIn={DELAY_PRESS_IN}
|
||||||
|
onPress={() => onPress?.(0)}
|
||||||
|
onLongPress={() => onLongPress(0)}>
|
||||||
<Image source={{uri: uris[0]}} style={size1} />
|
<Image source={{uri: uris[0]}} style={size1} />
|
||||||
</TouchableWithoutFeedback>
|
</TouchableOpacity>
|
||||||
<View style={styles.hSpace} />
|
<View style={styles.hSpace} />
|
||||||
<TouchableWithoutFeedback onPress={() => onPress?.(1)}>
|
<TouchableOpacity
|
||||||
|
delayPressIn={DELAY_PRESS_IN}
|
||||||
|
onPress={() => onPress?.(1)}
|
||||||
|
onLongPress={() => onLongPress(1)}>
|
||||||
<Image source={{uri: uris[1]}} style={size1} />
|
<Image source={{uri: uris[1]}} style={size1} />
|
||||||
</TouchableWithoutFeedback>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.wSpace} />
|
<View style={styles.wSpace} />
|
||||||
<View>
|
<View>
|
||||||
<TouchableWithoutFeedback onPress={() => onPress?.(2)}>
|
<TouchableOpacity
|
||||||
|
delayPressIn={DELAY_PRESS_IN}
|
||||||
|
onPress={() => onPress?.(2)}
|
||||||
|
onLongPress={() => onLongPress(2)}>
|
||||||
<Image source={{uri: uris[2]}} style={size1} />
|
<Image source={{uri: uris[2]}} style={size1} />
|
||||||
</TouchableWithoutFeedback>
|
</TouchableOpacity>
|
||||||
<View style={styles.hSpace} />
|
<View style={styles.hSpace} />
|
||||||
<TouchableWithoutFeedback onPress={() => onPress?.(3)}>
|
<TouchableOpacity
|
||||||
|
delayPressIn={DELAY_PRESS_IN}
|
||||||
|
onPress={() => onPress?.(3)}
|
||||||
|
onLongPress={() => onLongPress(3)}>
|
||||||
<Image source={{uri: uris[3]}} style={size1} />
|
<Image source={{uri: uris[3]}} style={size1} />
|
||||||
</TouchableWithoutFeedback>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
export const DELAY_PRESS_IN = 500
|
13
yarn.lock
13
yarn.lock
|
@ -2136,10 +2136,10 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
merge-options "^3.0.4"
|
merge-options "^3.0.4"
|
||||||
|
|
||||||
"@react-native-camera-roll/camera-roll@^5.1.0":
|
"@react-native-camera-roll/camera-roll@^5.2.2":
|
||||||
version "5.2.0"
|
version "5.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/@react-native-camera-roll/camera-roll/-/camera-roll-5.2.0.tgz#a30dca7c486379650c03fb8cc6fe35b7de6eeb82"
|
resolved "https://registry.yarnpkg.com/@react-native-camera-roll/camera-roll/-/camera-roll-5.2.2.tgz#dbdfa4ffb126b4d7efa01f3c5fc030ce3bfcdf2d"
|
||||||
integrity sha512-CIFkEqWeMtFo3fG/0nULrmLs8xikbOUuEty8wWxpyBWq7OM9Hi13pXJ1FWrIrxDcFuL7d0bxIqpqNrt59lAPrQ==
|
integrity sha512-LVzUX1KdKvOXJGiV/9tlkDyDSOEjvAzuiV8OkSUD13TXN/Tk5u2KVHTYRYJz5pmXanLN2dmEamctJcqKCeXYxg==
|
||||||
|
|
||||||
"@react-native-clipboard/clipboard@^1.10.0":
|
"@react-native-clipboard/clipboard@^1.10.0":
|
||||||
version "1.11.1"
|
version "1.11.1"
|
||||||
|
@ -11225,11 +11225,6 @@ react-native-image-crop-picker@^0.38.1:
|
||||||
resolved "https://registry.yarnpkg.com/react-native-image-crop-picker/-/react-native-image-crop-picker-0.38.1.tgz#5973b4a8b55835b987e6be2064de411e849ac005"
|
resolved "https://registry.yarnpkg.com/react-native-image-crop-picker/-/react-native-image-crop-picker-0.38.1.tgz#5973b4a8b55835b987e6be2064de411e849ac005"
|
||||||
integrity sha512-cF5UQnWplzHCeiCO+aiGS/0VomWaLmFf3nSsgTMPfY+8+99h8N/eHQvVdSF7RsGw50B8394wGeGyqHjjp8YRWw==
|
integrity sha512-cF5UQnWplzHCeiCO+aiGS/0VomWaLmFf3nSsgTMPfY+8+99h8N/eHQvVdSF7RsGw50B8394wGeGyqHjjp8YRWw==
|
||||||
|
|
||||||
react-native-image-viewing@^0.2.2:
|
|
||||||
version "0.2.2"
|
|
||||||
resolved "https://registry.yarnpkg.com/react-native-image-viewing/-/react-native-image-viewing-0.2.2.tgz#fb26e57d7d3d9ce4559a3af3d244387c0367242b"
|
|
||||||
integrity sha512-osWieG+p/d2NPbAyonOMubttajtYEYiRGQaJA54slFxZ69j1V4/dCmcrVQry47ktVKy8/qpFwCpW1eT6MH5T2Q==
|
|
||||||
|
|
||||||
react-native-inappbrowser-reborn@^3.6.3:
|
react-native-inappbrowser-reborn@^3.6.3:
|
||||||
version "3.7.0"
|
version "3.7.0"
|
||||||
resolved "https://registry.yarnpkg.com/react-native-inappbrowser-reborn/-/react-native-inappbrowser-reborn-3.7.0.tgz#849a43c3c7da22b65147649fe596836bcb494083"
|
resolved "https://registry.yarnpkg.com/react-native-inappbrowser-reborn/-/react-native-inappbrowser-reborn-3.7.0.tgz#849a43c3c7da22b65147649fe596836bcb494083"
|
||||||
|
|
Loading…
Reference in New Issue