mirror of
				https://github.com/tooot-app/app
				synced 2025-06-05 22:19:13 +02:00 
			
		
		
		
	Moved submodule to codebase
Due to using new dependencies such as `react-native-gesture-handler`
This commit is contained in:
		
							
								
								
									
										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
 | 
			
		||||
 Submodule src/modules/react-native-image-viewing deleted from bba2f756a9
									
								
							
							
								
								
									
										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}
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user