mirror of
https://github.com/tooot-app/app
synced 2025-02-18 04:40:57 +01:00
Moved submodule to codebase
Due to using new dependencies such as `react-native-gesture-handler`
This commit is contained in:
parent
69999dadb6
commit
2825e76dad
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@ -16,8 +16,6 @@ jobs:
|
||||
id: branch
|
||||
- name: -- Step 1 -- Checkout code
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: true
|
||||
- name: -- Step 2 -- Setup node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
|
3
.gitmodules
vendored
3
.gitmodules
vendored
@ -1,3 +0,0 @@
|
||||
[submodule "src/modules/react-native-image-viewing"]
|
||||
path = src/modules/react-native-image-viewing
|
||||
url = https://github.com/xmflsct/react-native-image-viewing.git
|
@ -1 +0,0 @@
|
||||
Subproject commit bba2f756a9d45c79a5ebf7e5e3124eac49b0c9f7
|
7
src/screens/ImageViewer/@types/extensions.d.ts
vendored
Normal file
7
src/screens/ImageViewer/@types/extensions.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
import * as rn from "react-native";
|
||||
|
||||
declare module "react-native" {
|
||||
class VirtualizedList<ItemT> extends React.Component<
|
||||
VirtualizedListProps<ItemT>
|
||||
> {}
|
||||
}
|
26
src/screens/ImageViewer/@types/index.ts
Normal file
26
src/screens/ImageViewer/@types/index.ts
Normal file
@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
export type ImageSource = {
|
||||
id: string
|
||||
preview_url: string
|
||||
remote_url?: string
|
||||
url: string
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
128
src/screens/ImageViewer/Root.tsx
Normal file
128
src/screens/ImageViewer/Root.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 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, { ComponentType, useCallback, useEffect } from 'react'
|
||||
import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
StyleSheet,
|
||||
View,
|
||||
VirtualizedList
|
||||
} from 'react-native'
|
||||
import { ImageSource } from './@types'
|
||||
import ImageItem from './components/ImageItem'
|
||||
import useAnimatedComponents from './hooks/useAnimatedComponents'
|
||||
import useImageIndexChange from './hooks/useImageIndexChange'
|
||||
import useRequestClose from './hooks/useRequestClose'
|
||||
|
||||
type Props = {
|
||||
images: ImageSource[]
|
||||
imageIndex: number
|
||||
onRequestClose: () => void
|
||||
onLongPress?: (image: ImageSource) => 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<ImageSource>>()
|
||||
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
|
117
src/screens/ImageViewer/components/ImageItem.android.tsx
Normal file
117
src/screens/ImageViewer/components/ImageItem.android.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 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 React, { useState, useCallback } from 'react'
|
||||
import { Animated, Dimensions, StyleSheet } from 'react-native'
|
||||
import { ImageSource } from '../@types'
|
||||
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: ImageSource
|
||||
onRequestClose: () => void
|
||||
onZoom: (isZoomed: boolean) => void
|
||||
onLongPress: (image: ImageSource) => 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 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Animated.ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
listItem: {
|
||||
width: SCREEN_WIDTH,
|
||||
height: SCREEN_HEIGHT
|
||||
},
|
||||
imageScrollContainer: {
|
||||
height: SCREEN_HEIGHT * 2
|
||||
}
|
||||
})
|
||||
|
||||
export default React.memo(ImageItem)
|
32
src/screens/ImageViewer/components/ImageItem.d.ts
vendored
Normal file
32
src/screens/ImageViewer/components/ImageItem.d.ts
vendored
Normal file
@ -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 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;
|
175
src/screens/ImageViewer/components/ImageItem.ios.tsx
Normal file
175
src/screens/ImageViewer/components/ImageItem.ios.tsx
Normal file
@ -0,0 +1,175 @@
|
||||
/**
|
||||
* 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 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 { ImageSource } from '../@types'
|
||||
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: ImageSource
|
||||
onRequestClose: () => void
|
||||
onZoom: (scaled: boolean) => void
|
||||
onLongPress: (image: ImageSource) => 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 || 0,
|
||||
height: imageSrc.height || 0
|
||||
})
|
||||
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)
|
40
src/screens/ImageViewer/hooks/useAnimatedComponents.ts
Normal file
40
src/screens/ImageViewer/hooks/useAnimatedComponents.ts
Normal file
@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 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
|
64
src/screens/ImageViewer/hooks/useDoubleTapToZoom.ts
Normal file
64
src/screens/ImageViewer/hooks/useDoubleTapToZoom.ts
Normal file
@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 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
|
31
src/screens/ImageViewer/hooks/useImageIndexChange.ts
Normal file
31
src/screens/ImageViewer/hooks/useImageIndexChange.ts
Normal file
@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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
|
407
src/screens/ImageViewer/hooks/usePanResponder.ts
Normal file
407
src/screens/ImageViewer/hooks/usePanResponder.ts
Normal file
@ -0,0 +1,407 @@
|
||||
/**
|
||||
* 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, useCallback } 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
|
||||
|
||||
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 &&
|
||||
(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) {
|
||||
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
|
23
src/screens/ImageViewer/hooks/useRequestClose.ts
Normal file
23
src/screens/ImageViewer/hooks/useRequestClose.ts
Normal file
@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 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
|
146
src/screens/ImageViewer/utils.ts
Normal file
146
src/screens/ImageViewer/utils.ts
Normal file
@ -0,0 +1,146 @@
|
||||
/**
|
||||
* 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) {
|
||||
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)
|
||||
)
|
||||
}
|
@ -4,7 +4,6 @@ import { HeaderCenter, HeaderLeft, HeaderRight } from '@components/Header'
|
||||
import { useActionSheet } from '@expo/react-native-action-sheet'
|
||||
import CameraRoll from '@react-native-community/cameraroll'
|
||||
import { StackScreenProps } from '@react-navigation/stack'
|
||||
import ImageView from '@root/modules/react-native-image-viewing/src/index'
|
||||
import { findIndex } from 'lodash'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -19,6 +18,36 @@ import {
|
||||
SafeAreaProvider,
|
||||
useSafeAreaInsets
|
||||
} from 'react-native-safe-area-context'
|
||||
import ImageViewer from './ImageViewer/Root'
|
||||
|
||||
type ImageUrl = {
|
||||
url: string
|
||||
width?: number | undefined
|
||||
height?: number | undefined
|
||||
preview_url: string
|
||||
remote_url?: string | undefined
|
||||
}
|
||||
|
||||
const saveImage = async (image: ImageUrl) => {
|
||||
const hasAndroidPermission = async () => {
|
||||
const permission = PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE
|
||||
|
||||
const hasPermission = await PermissionsAndroid.check(permission)
|
||||
if (hasPermission) {
|
||||
return true
|
||||
}
|
||||
|
||||
const status = await PermissionsAndroid.request(permission)
|
||||
return status === 'granted'
|
||||
}
|
||||
|
||||
if (Platform.OS === 'android' && !(await hasAndroidPermission())) {
|
||||
return
|
||||
}
|
||||
CameraRoll.save(image.url || image.remote_url || image.preview_url)
|
||||
.then(() => haptics('Success'))
|
||||
.catch(() => haptics('Error'))
|
||||
}
|
||||
|
||||
const HeaderComponent = React.memo(
|
||||
({
|
||||
@ -28,43 +57,12 @@ const HeaderComponent = React.memo(
|
||||
}: {
|
||||
navigation: ScreenImagesViewerProp['navigation']
|
||||
currentIndex: number
|
||||
imageUrls: {
|
||||
url: string
|
||||
width?: number | undefined
|
||||
height?: number | undefined
|
||||
preview_url: string
|
||||
remote_url?: string | undefined
|
||||
}[]
|
||||
imageUrls: ImageUrl[]
|
||||
}) => {
|
||||
const insets = useSafeAreaInsets()
|
||||
const { t } = useTranslation('screenImageViewer')
|
||||
const { showActionSheetWithOptions } = useActionSheet()
|
||||
|
||||
const hasAndroidPermission = async () => {
|
||||
const permission = PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE
|
||||
|
||||
const hasPermission = await PermissionsAndroid.check(permission)
|
||||
if (hasPermission) {
|
||||
return true
|
||||
}
|
||||
|
||||
const status = await PermissionsAndroid.request(permission)
|
||||
return status === 'granted'
|
||||
}
|
||||
|
||||
const saveImage = async () => {
|
||||
if (Platform.OS === 'android' && !(await hasAndroidPermission())) {
|
||||
return
|
||||
}
|
||||
CameraRoll.save(
|
||||
imageUrls[currentIndex].url ||
|
||||
imageUrls[currentIndex].remote_url ||
|
||||
imageUrls[currentIndex].preview_url
|
||||
)
|
||||
.then(() => haptics('Success'))
|
||||
.catch(() => haptics('Error'))
|
||||
}
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
analytics('imageviewer_more_press')
|
||||
showActionSheetWithOptions(
|
||||
@ -80,7 +78,7 @@ const HeaderComponent = React.memo(
|
||||
switch (buttonIndex) {
|
||||
case 0:
|
||||
analytics('imageviewer_more_save_press')
|
||||
saveImage()
|
||||
saveImage(imageUrls[currentIndex])
|
||||
break
|
||||
case 1:
|
||||
analytics('imageviewer_more_share_press')
|
||||
@ -147,11 +145,12 @@ const ScreenImagesViewer = ({
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<StatusBar backgroundColor='rgb(0,0,0)' />
|
||||
<ImageView
|
||||
<ImageViewer
|
||||
images={imageUrls}
|
||||
imageIndex={initialIndex}
|
||||
onImageIndexChange={index => setCurrentIndex(index)}
|
||||
onRequestClose={() => navigation.goBack()}
|
||||
onLongPress={saveImage}
|
||||
HeaderComponent={() => (
|
||||
<HeaderComponent
|
||||
navigation={navigation}
|
||||
|
Loading…
x
Reference in New Issue
Block a user