Rewrite Android lightbox (#1624)
parent
8366fe2c4a
commit
64153067e3
|
@ -1,159 +1,398 @@
|
||||||
/**
|
import React, {MutableRefObject, useState} from 'react'
|
||||||
* Copyright (c) JOB TODAY S.A. and its affiliates.
|
|
||||||
*
|
|
||||||
* This source code is licensed under the MIT license found in the
|
|
||||||
* LICENSE file in the root directory of this source tree.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, {useCallback, useRef, useState} from 'react'
|
import {ActivityIndicator, Dimensions, StyleSheet} from 'react-native'
|
||||||
|
|
||||||
import {
|
|
||||||
Animated,
|
|
||||||
ScrollView,
|
|
||||||
Dimensions,
|
|
||||||
StyleSheet,
|
|
||||||
NativeScrollEvent,
|
|
||||||
NativeSyntheticEvent,
|
|
||||||
NativeMethodsMixin,
|
|
||||||
} from 'react-native'
|
|
||||||
import {Image} from 'expo-image'
|
import {Image} from 'expo-image'
|
||||||
|
import Animated, {
|
||||||
|
measure,
|
||||||
|
runOnJS,
|
||||||
|
useAnimatedRef,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useAnimatedReaction,
|
||||||
|
useSharedValue,
|
||||||
|
withDecay,
|
||||||
|
withSpring,
|
||||||
|
} from 'react-native-reanimated'
|
||||||
|
import {
|
||||||
|
GestureDetector,
|
||||||
|
Gesture,
|
||||||
|
GestureType,
|
||||||
|
} from 'react-native-gesture-handler'
|
||||||
import useImageDimensions from '../../hooks/useImageDimensions'
|
import useImageDimensions from '../../hooks/useImageDimensions'
|
||||||
import usePanResponder from '../../hooks/usePanResponder'
|
import {
|
||||||
|
createTransform,
|
||||||
|
readTransform,
|
||||||
|
applyRounding,
|
||||||
|
prependPan,
|
||||||
|
prependPinch,
|
||||||
|
prependTransform,
|
||||||
|
TransformMatrix,
|
||||||
|
} from '../../transforms'
|
||||||
|
import type {ImageSource, Dimensions as ImageDimensions} from '../../@types'
|
||||||
|
|
||||||
import {getImageTransform} from '../../utils'
|
|
||||||
import {ImageSource} from '../../@types'
|
|
||||||
import {ImageLoading} from './ImageLoading'
|
|
||||||
|
|
||||||
const SWIPE_CLOSE_OFFSET = 75
|
|
||||||
const SWIPE_CLOSE_VELOCITY = 1.75
|
|
||||||
const SCREEN = Dimensions.get('window')
|
const SCREEN = Dimensions.get('window')
|
||||||
const SCREEN_WIDTH = SCREEN.width
|
const MIN_DOUBLE_TAP_SCALE = 2
|
||||||
const SCREEN_HEIGHT = SCREEN.height
|
const MAX_ORIGINAL_IMAGE_ZOOM = 2
|
||||||
|
|
||||||
|
const AnimatedImage = Animated.createAnimatedComponent(Image)
|
||||||
|
const initialTransform = createTransform()
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
imageSrc: ImageSource
|
imageSrc: ImageSource
|
||||||
onRequestClose: () => void
|
onRequestClose: () => void
|
||||||
onZoom: (isZoomed: boolean) => void
|
onZoom: (isZoomed: boolean) => void
|
||||||
|
pinchGestureRef: MutableRefObject<GestureType | undefined>
|
||||||
|
isScrollViewBeingDragged: boolean
|
||||||
}
|
}
|
||||||
|
const ImageItem = ({
|
||||||
const AnimatedImage = Animated.createAnimatedComponent(Image)
|
imageSrc,
|
||||||
|
onZoom,
|
||||||
const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => {
|
onRequestClose,
|
||||||
const imageContainer = useRef<ScrollView & NativeMethodsMixin>(null)
|
isScrollViewBeingDragged,
|
||||||
|
pinchGestureRef,
|
||||||
|
}: Props) => {
|
||||||
|
const [isScaled, setIsScaled] = useState(false)
|
||||||
|
const [isLoaded, setIsLoaded] = useState(false)
|
||||||
const imageDimensions = useImageDimensions(imageSrc)
|
const imageDimensions = useImageDimensions(imageSrc)
|
||||||
const [translate, scale] = getImageTransform(imageDimensions, SCREEN)
|
const committedTransform = useSharedValue(initialTransform)
|
||||||
const scrollValueY = new Animated.Value(0)
|
const panTranslation = useSharedValue({x: 0, y: 0})
|
||||||
const [isLoaded, setLoadEnd] = useState(false)
|
const pinchOrigin = useSharedValue({x: 0, y: 0})
|
||||||
|
const pinchScale = useSharedValue(1)
|
||||||
|
const pinchTranslation = useSharedValue({x: 0, y: 0})
|
||||||
|
const dismissSwipeTranslateY = useSharedValue(0)
|
||||||
|
const containerRef = useAnimatedRef()
|
||||||
|
|
||||||
const onLoaded = useCallback(() => setLoadEnd(true), [])
|
function getCommittedScale(): number {
|
||||||
const onZoomPerformed = useCallback(
|
'worklet'
|
||||||
(isZoomed: boolean) => {
|
const [, , committedScale] = readTransform(committedTransform.value)
|
||||||
onZoom(isZoomed)
|
return committedScale
|
||||||
if (imageContainer?.current) {
|
}
|
||||||
imageContainer.current.setNativeProps({
|
|
||||||
scrollEnabled: !isZoomed,
|
// Keep track of when we're entering or leaving scaled rendering.
|
||||||
})
|
useAnimatedReaction(
|
||||||
|
() => {
|
||||||
|
return pinchScale.value !== 1 || getCommittedScale() !== 1
|
||||||
|
},
|
||||||
|
(nextIsScaled, prevIsScaled) => {
|
||||||
|
if (nextIsScaled !== prevIsScaled) {
|
||||||
|
runOnJS(handleZoom)(nextIsScaled)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onZoom],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const [panHandlers, scaleValue, translateValue] = usePanResponder({
|
function handleZoom(nextIsScaled: boolean) {
|
||||||
initialScale: scale || 1,
|
setIsScaled(nextIsScaled)
|
||||||
initialTranslate: translate || {x: 0, y: 0},
|
onZoom(nextIsScaled)
|
||||||
onZoom: onZoomPerformed,
|
}
|
||||||
})
|
|
||||||
|
|
||||||
const imagesStyles = getImageStyles(
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
imageDimensions,
|
// Apply the active adjustments on top of the committed transform before the gestures.
|
||||||
translateValue,
|
// This is matrix multiplication, so operations are applied in the reverse order.
|
||||||
scaleValue,
|
let t = createTransform()
|
||||||
|
prependPan(t, panTranslation.value)
|
||||||
|
prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value)
|
||||||
|
prependTransform(t, committedTransform.value)
|
||||||
|
const [translateX, translateY, scale] = readTransform(t)
|
||||||
|
|
||||||
|
const dismissDistance = dismissSwipeTranslateY.value
|
||||||
|
const dismissProgress = Math.min(
|
||||||
|
Math.abs(dismissDistance) / (SCREEN.height / 2),
|
||||||
|
1,
|
||||||
)
|
)
|
||||||
const imageOpacity = scrollValueY.interpolate({
|
return {
|
||||||
inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
|
opacity: 1 - dismissProgress,
|
||||||
outputRange: [0.7, 1, 0.7],
|
transform: [
|
||||||
|
{translateX},
|
||||||
|
{translateY: translateY + dismissDistance},
|
||||||
|
{scale},
|
||||||
|
],
|
||||||
|
}
|
||||||
})
|
})
|
||||||
const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity}
|
|
||||||
|
|
||||||
const onScrollEndDrag = ({
|
// On Android, stock apps prevent going "out of bounds" on pan or pinch. You should "bump" into edges.
|
||||||
nativeEvent,
|
// If the user tried to pan too hard, this function will provide the negative panning to stay in bounds.
|
||||||
}: NativeSyntheticEvent<NativeScrollEvent>) => {
|
function getExtraTranslationToStayInBounds(
|
||||||
const velocityY = nativeEvent?.velocity?.y ?? 0
|
candidateTransform: TransformMatrix,
|
||||||
const offsetY = nativeEvent?.contentOffset?.y ?? 0
|
|
||||||
|
|
||||||
if (
|
|
||||||
(Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY &&
|
|
||||||
offsetY > SWIPE_CLOSE_OFFSET) ||
|
|
||||||
offsetY > SCREEN_HEIGHT / 2
|
|
||||||
) {
|
) {
|
||||||
onRequestClose()
|
'worklet'
|
||||||
|
if (!imageDimensions) {
|
||||||
|
return [0, 0]
|
||||||
}
|
}
|
||||||
|
const [nextTranslateX, nextTranslateY, nextScale] =
|
||||||
|
readTransform(candidateTransform)
|
||||||
|
const scaledDimensions = getScaledDimensions(imageDimensions, nextScale)
|
||||||
|
const clampedTranslateX = clampTranslation(
|
||||||
|
nextTranslateX,
|
||||||
|
scaledDimensions.width,
|
||||||
|
SCREEN.width,
|
||||||
|
)
|
||||||
|
const clampedTranslateY = clampTranslation(
|
||||||
|
nextTranslateY,
|
||||||
|
scaledDimensions.height,
|
||||||
|
SCREEN.height,
|
||||||
|
)
|
||||||
|
const dx = clampedTranslateX - nextTranslateX
|
||||||
|
const dy = clampedTranslateY - nextTranslateY
|
||||||
|
return [dx, dy]
|
||||||
}
|
}
|
||||||
|
|
||||||
const onScroll = ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => {
|
// This is a hack.
|
||||||
const offsetY = nativeEvent?.contentOffset?.y ?? 0
|
// We need to disallow any gestures (and let the native parent scroll view scroll) while you're scrolling it.
|
||||||
|
// However, there is no great reliable way to coordinate this yet in RGNH.
|
||||||
|
// This "fake" manual gesture handler whenever you're trying to touch something while the parent scrollview is not at rest.
|
||||||
|
const consumeHScroll = Gesture.Manual().onTouchesDown((e, manager) => {
|
||||||
|
if (isScrollViewBeingDragged) {
|
||||||
|
// Steal the gesture (and do nothing, so native ScrollView does its thing).
|
||||||
|
manager.activate()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const measurement = measure(containerRef)
|
||||||
|
if (!measurement || measurement.pageX !== 0) {
|
||||||
|
// Steal the gesture (and do nothing, so native ScrollView does its thing).
|
||||||
|
manager.activate()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Fail this "fake" gesture so that the gestures after it can proceed.
|
||||||
|
manager.fail()
|
||||||
|
})
|
||||||
|
|
||||||
scrollValueY.setValue(offsetY)
|
const pinch = Gesture.Pinch()
|
||||||
|
.withRef(pinchGestureRef)
|
||||||
|
.onStart(e => {
|
||||||
|
pinchOrigin.value = {
|
||||||
|
x: e.focalX - SCREEN.width / 2,
|
||||||
|
y: e.focalY - SCREEN.height / 2,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.onChange(e => {
|
||||||
|
if (!imageDimensions) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Don't let the picture zoom in so close that it gets blurry.
|
||||||
|
// Also, like in stock Android apps, don't let the user zoom out further than 1:1.
|
||||||
|
const committedScale = getCommittedScale()
|
||||||
|
const maxCommittedScale =
|
||||||
|
(imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM
|
||||||
|
const minPinchScale = 1 / committedScale
|
||||||
|
const maxPinchScale = maxCommittedScale / committedScale
|
||||||
|
const nextPinchScale = Math.min(
|
||||||
|
Math.max(minPinchScale, e.scale),
|
||||||
|
maxPinchScale,
|
||||||
|
)
|
||||||
|
pinchScale.value = nextPinchScale
|
||||||
|
|
||||||
|
// Zooming out close to the corner could push us out of bounds, which we don't want on Android.
|
||||||
|
// Calculate where we'll end up so we know how much to translate back to stay in bounds.
|
||||||
|
const t = createTransform()
|
||||||
|
prependPan(t, panTranslation.value)
|
||||||
|
prependPinch(t, nextPinchScale, pinchOrigin.value, pinchTranslation.value)
|
||||||
|
prependTransform(t, committedTransform.value)
|
||||||
|
const [dx, dy] = getExtraTranslationToStayInBounds(t)
|
||||||
|
if (dx !== 0 || dy !== 0) {
|
||||||
|
pinchTranslation.value = {
|
||||||
|
x: pinchTranslation.value.x + dx,
|
||||||
|
y: pinchTranslation.value.y + dy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.onEnd(() => {
|
||||||
|
// Commit just the pinch.
|
||||||
|
let t = createTransform()
|
||||||
|
prependPinch(
|
||||||
|
t,
|
||||||
|
pinchScale.value,
|
||||||
|
pinchOrigin.value,
|
||||||
|
pinchTranslation.value,
|
||||||
|
)
|
||||||
|
prependTransform(t, committedTransform.value)
|
||||||
|
applyRounding(t)
|
||||||
|
committedTransform.value = t
|
||||||
|
|
||||||
|
// Reset just the pinch.
|
||||||
|
pinchScale.value = 1
|
||||||
|
pinchOrigin.value = {x: 0, y: 0}
|
||||||
|
pinchTranslation.value = {x: 0, y: 0}
|
||||||
|
})
|
||||||
|
|
||||||
|
const pan = Gesture.Pan()
|
||||||
|
.averageTouches(true)
|
||||||
|
// Unlike .enabled(isScaled), this ensures that an initial pinch can turn into a pan midway:
|
||||||
|
.minPointers(isScaled ? 1 : 2)
|
||||||
|
.onChange(e => {
|
||||||
|
if (!imageDimensions) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const nextPanTranslation = {x: e.translationX, y: e.translationY}
|
||||||
|
let t = createTransform()
|
||||||
|
prependPan(t, nextPanTranslation)
|
||||||
|
prependPinch(
|
||||||
|
t,
|
||||||
|
pinchScale.value,
|
||||||
|
pinchOrigin.value,
|
||||||
|
pinchTranslation.value,
|
||||||
|
)
|
||||||
|
prependTransform(t, committedTransform.value)
|
||||||
|
|
||||||
|
// Prevent panning from going out of bounds.
|
||||||
|
const [dx, dy] = getExtraTranslationToStayInBounds(t)
|
||||||
|
nextPanTranslation.x += dx
|
||||||
|
nextPanTranslation.y += dy
|
||||||
|
panTranslation.value = nextPanTranslation
|
||||||
|
})
|
||||||
|
.onEnd(() => {
|
||||||
|
// Commit just the pan.
|
||||||
|
let t = createTransform()
|
||||||
|
prependPan(t, panTranslation.value)
|
||||||
|
prependTransform(t, committedTransform.value)
|
||||||
|
applyRounding(t)
|
||||||
|
committedTransform.value = t
|
||||||
|
|
||||||
|
// Reset just the pan.
|
||||||
|
panTranslation.value = {x: 0, y: 0}
|
||||||
|
})
|
||||||
|
|
||||||
|
const doubleTap = Gesture.Tap()
|
||||||
|
.numberOfTaps(2)
|
||||||
|
.onEnd(e => {
|
||||||
|
if (!imageDimensions) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const committedScale = getCommittedScale()
|
||||||
|
if (committedScale !== 1) {
|
||||||
|
// Go back to 1:1 using the identity vector.
|
||||||
|
let t = createTransform()
|
||||||
|
committedTransform.value = withClampedSpring(t)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try to zoom in so that we get rid of the black bars (whatever the orientation was).
|
||||||
|
const imageAspect = imageDimensions.width / imageDimensions.height
|
||||||
|
const screenAspect = SCREEN.width / SCREEN.height
|
||||||
|
const candidateScale = Math.max(
|
||||||
|
imageAspect / screenAspect,
|
||||||
|
screenAspect / imageAspect,
|
||||||
|
MIN_DOUBLE_TAP_SCALE,
|
||||||
|
)
|
||||||
|
// But don't zoom in so close that the picture gets blurry.
|
||||||
|
const maxScale =
|
||||||
|
(imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM
|
||||||
|
const scale = Math.min(candidateScale, maxScale)
|
||||||
|
|
||||||
|
// Calculate where we would be if the user pinched into the double tapped point.
|
||||||
|
// We won't use this transform directly because it may go out of bounds.
|
||||||
|
const candidateTransform = createTransform()
|
||||||
|
const origin = {
|
||||||
|
x: e.absoluteX - SCREEN.width / 2,
|
||||||
|
y: e.absoluteY - SCREEN.height / 2,
|
||||||
|
}
|
||||||
|
prependPinch(candidateTransform, scale, origin, {x: 0, y: 0})
|
||||||
|
|
||||||
|
// Now we know how much we went out of bounds, so we can shoot correctly.
|
||||||
|
const [dx, dy] = getExtraTranslationToStayInBounds(candidateTransform)
|
||||||
|
const finalTransform = createTransform()
|
||||||
|
prependPinch(finalTransform, scale, origin, {x: dx, y: dy})
|
||||||
|
committedTransform.value = withClampedSpring(finalTransform)
|
||||||
|
})
|
||||||
|
|
||||||
|
const dismissSwipePan = Gesture.Pan()
|
||||||
|
.enabled(!isScaled)
|
||||||
|
.activeOffsetY([-10, 10])
|
||||||
|
.failOffsetX([-10, 10])
|
||||||
|
.maxPointers(1)
|
||||||
|
.onUpdate(e => {
|
||||||
|
dismissSwipeTranslateY.value = e.translationY
|
||||||
|
})
|
||||||
|
.onEnd(e => {
|
||||||
|
if (Math.abs(e.velocityY) > 1000) {
|
||||||
|
dismissSwipeTranslateY.value = withDecay({velocity: e.velocityY})
|
||||||
|
runOnJS(onRequestClose)()
|
||||||
|
} else {
|
||||||
|
dismissSwipeTranslateY.value = withSpring(0, {
|
||||||
|
stiffness: 700,
|
||||||
|
damping: 50,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const isLoading = !isLoaded || !imageDimensions
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<Animated.View ref={containerRef} style={styles.container}>
|
||||||
ref={imageContainer}
|
{isLoading && (
|
||||||
style={styles.listItem}
|
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
|
||||||
pagingEnabled
|
)}
|
||||||
nestedScrollEnabled
|
<GestureDetector
|
||||||
showsHorizontalScrollIndicator={false}
|
gesture={Gesture.Exclusive(
|
||||||
showsVerticalScrollIndicator={false}
|
consumeHScroll,
|
||||||
contentContainerStyle={styles.imageScrollContainer}
|
dismissSwipePan,
|
||||||
scrollEnabled={true}
|
Gesture.Simultaneous(pinch, pan),
|
||||||
onScroll={onScroll}
|
doubleTap,
|
||||||
onScrollEndDrag={onScrollEndDrag}>
|
)}>
|
||||||
<AnimatedImage
|
<AnimatedImage
|
||||||
{...panHandlers}
|
|
||||||
source={imageSrc}
|
source={imageSrc}
|
||||||
style={imageStylesWithOpacity}
|
contentFit="contain"
|
||||||
onLoad={onLoaded}
|
style={[styles.image, animatedStyle]}
|
||||||
accessibilityLabel={imageSrc.alt}
|
accessibilityLabel={imageSrc.alt}
|
||||||
accessibilityHint=""
|
accessibilityHint=""
|
||||||
|
onLoad={() => setIsLoaded(true)}
|
||||||
/>
|
/>
|
||||||
{(!isLoaded || !imageDimensions) && <ImageLoading />}
|
</GestureDetector>
|
||||||
</ScrollView>
|
</Animated.View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
listItem: {
|
container: {
|
||||||
width: SCREEN_WIDTH,
|
width: SCREEN.width,
|
||||||
height: SCREEN_HEIGHT,
|
height: SCREEN.height,
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
imageScrollContainer: {
|
image: {
|
||||||
height: SCREEN_HEIGHT * 2,
|
flex: 1,
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const getImageStyles = (
|
function getScaledDimensions(
|
||||||
image: {width: number; height: number} | null,
|
imageDimensions: ImageDimensions,
|
||||||
translate: Animated.ValueXY,
|
scale: number,
|
||||||
scale?: Animated.Value,
|
): ImageDimensions {
|
||||||
) => {
|
'worklet'
|
||||||
if (!image?.width || !image?.height) {
|
const imageAspect = imageDimensions.width / imageDimensions.height
|
||||||
return {width: 0, height: 0}
|
const screenAspect = SCREEN.width / SCREEN.height
|
||||||
}
|
const isLandscape = imageAspect > screenAspect
|
||||||
|
if (isLandscape) {
|
||||||
const transform = translate.getTranslateTransform()
|
|
||||||
|
|
||||||
if (scale) {
|
|
||||||
// @ts-ignore TODO - is scale incorrect? might need to remove -prf
|
|
||||||
transform.push({scale}, {perspective: new Animated.Value(1000)})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
width: image.width,
|
width: scale * SCREEN.width,
|
||||||
height: image.height,
|
height: (scale * SCREEN.width) / imageAspect,
|
||||||
transform,
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
width: scale * SCREEN.height * imageAspect,
|
||||||
|
height: scale * SCREEN.height,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clampTranslation(
|
||||||
|
value: number,
|
||||||
|
scaledSize: number,
|
||||||
|
screenSize: number,
|
||||||
|
): number {
|
||||||
|
'worklet'
|
||||||
|
// Figure out how much the user should be allowed to pan, and constrain the translation.
|
||||||
|
const panDistance = Math.max(0, (scaledSize - screenSize) / 2)
|
||||||
|
const clampedValue = Math.min(Math.max(-panDistance, value), panDistance)
|
||||||
|
return clampedValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function withClampedSpring(value: any) {
|
||||||
|
'worklet'
|
||||||
|
return withSpring(value, {overshootClamping: true})
|
||||||
|
}
|
||||||
|
|
||||||
export default React.memo(ImageItem)
|
export default React.memo(ImageItem)
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, {useCallback, useRef, useState} from 'react'
|
import React, {MutableRefObject, useCallback, useRef, useState} from 'react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Animated,
|
Animated,
|
||||||
|
@ -20,11 +20,11 @@ import {
|
||||||
TouchableWithoutFeedback,
|
TouchableWithoutFeedback,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import {Image} from 'expo-image'
|
import {Image} from 'expo-image'
|
||||||
|
import {GestureType} from 'react-native-gesture-handler'
|
||||||
|
|
||||||
import useImageDimensions from '../../hooks/useImageDimensions'
|
import useImageDimensions from '../../hooks/useImageDimensions'
|
||||||
|
|
||||||
import {getImageTransform} from '../../utils'
|
import {ImageSource, Dimensions as ImageDimensions} from '../../@types'
|
||||||
import {ImageSource} from '../../@types'
|
|
||||||
import {ImageLoading} from './ImageLoading'
|
import {ImageLoading} from './ImageLoading'
|
||||||
|
|
||||||
const DOUBLE_TAP_DELAY = 300
|
const DOUBLE_TAP_DELAY = 300
|
||||||
|
@ -40,6 +40,8 @@ type Props = {
|
||||||
imageSrc: ImageSource
|
imageSrc: ImageSource
|
||||||
onRequestClose: () => void
|
onRequestClose: () => void
|
||||||
onZoom: (scaled: boolean) => void
|
onZoom: (scaled: boolean) => void
|
||||||
|
pinchGestureRef: MutableRefObject<GestureType>
|
||||||
|
isScrollViewBeingDragged: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const AnimatedImage = Animated.createAnimatedComponent(Image)
|
const AnimatedImage = Animated.createAnimatedComponent(Image)
|
||||||
|
@ -164,7 +166,7 @@ const styles = StyleSheet.create({
|
||||||
})
|
})
|
||||||
|
|
||||||
const getZoomRectAfterDoubleTap = (
|
const getZoomRectAfterDoubleTap = (
|
||||||
imageDimensions: {width: number; height: number} | null,
|
imageDimensions: ImageDimensions | null,
|
||||||
touchX: number,
|
touchX: number,
|
||||||
touchY: number,
|
touchY: number,
|
||||||
): {
|
): {
|
||||||
|
@ -252,7 +254,7 @@ const getZoomRectAfterDoubleTap = (
|
||||||
}
|
}
|
||||||
|
|
||||||
const getImageStyles = (
|
const getImageStyles = (
|
||||||
image: {width: number; height: number} | null,
|
image: ImageDimensions | null,
|
||||||
translate: {readonly x: number; readonly y: number} | undefined,
|
translate: {readonly x: number; readonly y: number} | undefined,
|
||||||
scale?: number,
|
scale?: number,
|
||||||
) => {
|
) => {
|
||||||
|
@ -275,4 +277,37 @@ const getImageStyles = (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getImageTransform = (
|
||||||
|
image: ImageDimensions | null,
|
||||||
|
screen: ImageDimensions,
|
||||||
|
) => {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
const getImageTranslate = (
|
||||||
|
image: ImageDimensions,
|
||||||
|
screen: ImageDimensions,
|
||||||
|
): {x: number; y: number} => {
|
||||||
|
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 default React.memo(ImageItem)
|
export default React.memo(ImageItem)
|
||||||
|
|
|
@ -1,13 +1,16 @@
|
||||||
// default implementation fallback for web
|
// default implementation fallback for web
|
||||||
|
|
||||||
import React from 'react'
|
import React, {MutableRefObject} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
|
import {GestureType} from 'react-native-gesture-handler'
|
||||||
import {ImageSource} from '../../@types'
|
import {ImageSource} from '../../@types'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
imageSrc: ImageSource
|
imageSrc: ImageSource
|
||||||
onRequestClose: () => void
|
onRequestClose: () => void
|
||||||
onZoom: (scaled: boolean) => void
|
onZoom: (scaled: boolean) => void
|
||||||
|
pinchGestureRef: MutableRefObject<GestureType | undefined>
|
||||||
|
isScrollViewBeingDragged: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const ImageItem = (_props: Props) => {
|
const ImageItem = (_props: Props) => {
|
||||||
|
|
|
@ -1,423 +0,0 @@
|
||||||
/**
|
|
||||||
* Copyright (c) JOB TODAY S.A. and its affiliates.
|
|
||||||
*
|
|
||||||
* This source code is licensed under the MIT license found in the
|
|
||||||
* LICENSE file in the root directory of this source tree.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {useEffect} from 'react'
|
|
||||||
import {
|
|
||||||
Animated,
|
|
||||||
Dimensions,
|
|
||||||
GestureResponderEvent,
|
|
||||||
GestureResponderHandlers,
|
|
||||||
NativeTouchEvent,
|
|
||||||
PanResponder,
|
|
||||||
PanResponderGestureState,
|
|
||||||
} from 'react-native'
|
|
||||||
|
|
||||||
import {Position} from '../@types'
|
|
||||||
import {getImageTranslate} from '../utils'
|
|
||||||
|
|
||||||
const SCREEN = Dimensions.get('window')
|
|
||||||
const SCREEN_WIDTH = SCREEN.width
|
|
||||||
const SCREEN_HEIGHT = SCREEN.height
|
|
||||||
const ANDROID_BAR_HEIGHT = 24
|
|
||||||
|
|
||||||
const MIN_ZOOM = 2
|
|
||||||
const MAX_SCALE = 2
|
|
||||||
const DOUBLE_TAP_DELAY = 300
|
|
||||||
const OUT_BOUND_MULTIPLIER = 0.75
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
initialScale: number
|
|
||||||
initialTranslate: Position
|
|
||||||
onZoom: (isZoomed: boolean) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const usePanResponder = ({
|
|
||||||
initialScale,
|
|
||||||
initialTranslate,
|
|
||||||
onZoom,
|
|
||||||
}: 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
|
|
||||||
|
|
||||||
// TODO: It's not valid to reinitialize Animated values during render.
|
|
||||||
// This is a bug.
|
|
||||||
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 getTransformAfterDoubleTap = (
|
|
||||||
touchX: number,
|
|
||||||
touchY: number,
|
|
||||||
): [number, Position] => {
|
|
||||||
let nextScale = initialScale
|
|
||||||
let nextTranslateX = initialTranslate.x
|
|
||||||
let nextTranslateY = initialTranslate.y
|
|
||||||
|
|
||||||
// First, let's figure out how much we want to zoom in.
|
|
||||||
// We want to try to zoom in at least close enough to get rid of black bars.
|
|
||||||
const imageAspect = imageDimensions.width / imageDimensions.height
|
|
||||||
const screenAspect = SCREEN.width / SCREEN.height
|
|
||||||
let zoom = Math.max(
|
|
||||||
imageAspect / screenAspect,
|
|
||||||
screenAspect / imageAspect,
|
|
||||||
MIN_ZOOM,
|
|
||||||
)
|
|
||||||
// Don't zoom so hard that the original image's pixels become blurry.
|
|
||||||
zoom = Math.min(zoom, MAX_SCALE / initialScale)
|
|
||||||
nextScale = initialScale * zoom
|
|
||||||
|
|
||||||
// Next, let's see if we need to adjust the scaled image translation.
|
|
||||||
// Ideally, we want the tapped point to stay under the finger after the scaling.
|
|
||||||
const dx = SCREEN.width / 2 - touchX
|
|
||||||
const dy = SCREEN.height / 2 - (touchY - ANDROID_BAR_HEIGHT)
|
|
||||||
// Before we try to adjust the translation, check how much wiggle room we have.
|
|
||||||
// We don't want to introduce new black bars or make existing black bars unbalanced.
|
|
||||||
const [topBound, leftBound, bottomBound, rightBound] = getBounds(nextScale)
|
|
||||||
if (leftBound > rightBound) {
|
|
||||||
// Content fills the screen horizontally so we have horizontal wiggle room.
|
|
||||||
// Try to keep the tapped point under the finger after zoom.
|
|
||||||
nextTranslateX += dx * zoom - dx
|
|
||||||
nextTranslateX = Math.min(nextTranslateX, leftBound)
|
|
||||||
nextTranslateX = Math.max(nextTranslateX, rightBound)
|
|
||||||
}
|
|
||||||
if (topBound > bottomBound) {
|
|
||||||
// Content fills the screen vertically so we have vertical wiggle room.
|
|
||||||
// Try to keep the tapped point under the finger after zoom.
|
|
||||||
nextTranslateY += dy * zoom - dy
|
|
||||||
nextTranslateY = Math.min(nextTranslateY, topBound)
|
|
||||||
nextTranslateY = Math.max(nextTranslateY, bottomBound)
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
nextScale,
|
|
||||||
{
|
|
||||||
x: nextTranslateX,
|
|
||||||
y: nextTranslateY,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
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 panResponder = PanResponder.create({
|
|
||||||
onStartShouldSetPanResponder: () => true,
|
|
||||||
onStartShouldSetPanResponderCapture: () => true,
|
|
||||||
onMoveShouldSetPanResponder: () => true,
|
|
||||||
onMoveShouldSetPanResponderCapture: () => true,
|
|
||||||
onPanResponderGrant: (
|
|
||||||
_: GestureResponderEvent,
|
|
||||||
gestureState: PanResponderGestureState,
|
|
||||||
) => {
|
|
||||||
numberInitialTouches = gestureState.numberActiveTouches
|
|
||||||
|
|
||||||
if (gestureState.numberActiveTouches > 1) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onPanResponderStart: (
|
|
||||||
event: GestureResponderEvent,
|
|
||||||
gestureState: PanResponderGestureState,
|
|
||||||
) => {
|
|
||||||
initialTouches = event.nativeEvent.touches
|
|
||||||
numberInitialTouches = gestureState.numberActiveTouches
|
|
||||||
|
|
||||||
if (gestureState.numberActiveTouches > 1) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const tapTS = Date.now()
|
|
||||||
// Handle double tap event by calculating diff between first and second taps timestamps
|
|
||||||
|
|
||||||
isDoubleTapPerformed = Boolean(
|
|
||||||
lastTapTS && tapTS - lastTapTS < DOUBLE_TAP_DELAY,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isDoubleTapPerformed) {
|
|
||||||
let nextScale = initialScale
|
|
||||||
let nextTranslate = initialTranslate
|
|
||||||
|
|
||||||
const willZoom = currentScale === initialScale
|
|
||||||
if (willZoom) {
|
|
||||||
const {pageX: touchX, pageY: touchY} = event.nativeEvent.touches[0]
|
|
||||||
;[nextScale, nextTranslate] = getTransformAfterDoubleTap(
|
|
||||||
touchX,
|
|
||||||
touchY,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onZoom(willZoom)
|
|
||||||
|
|
||||||
Animated.parallel(
|
|
||||||
[
|
|
||||||
Animated.timing(translateValue.x, {
|
|
||||||
toValue: nextTranslate.x,
|
|
||||||
duration: 300,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
Animated.timing(translateValue.y, {
|
|
||||||
toValue: nextTranslate.y,
|
|
||||||
duration: 300,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
Animated.timing(scaleValue, {
|
|
||||||
toValue: nextScale,
|
|
||||||
duration: 300,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
{stopTogether: false},
|
|
||||||
).start(() => {
|
|
||||||
currentScale = nextScale
|
|
||||||
currentTranslate = nextTranslate
|
|
||||||
})
|
|
||||||
|
|
||||||
lastTapTS = null
|
|
||||||
} else {
|
|
||||||
lastTapTS = Date.now()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onPanResponderMove: (
|
|
||||||
event: GestureResponderEvent,
|
|
||||||
gestureState: PanResponderGestureState,
|
|
||||||
) => {
|
|
||||||
// Don't need to handle move because double tap in progress (was handled in onStart)
|
|
||||||
if (isDoubleTapPerformed) {
|
|
||||||
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) {
|
|
||||||
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}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onPanResponderRelease: () => {
|
|
||||||
if (isDoubleTapPerformed) {
|
|
||||||
isDoubleTapPerformed = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tmpScale > 0) {
|
|
||||||
if (tmpScale < initialScale || tmpScale > MAX_SCALE) {
|
|
||||||
tmpScale = tmpScale < initialScale ? initialScale : MAX_SCALE
|
|
||||||
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
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onPanResponderTerminationRequest: () => false,
|
|
||||||
onShouldBlockNativeResponder: () => false,
|
|
||||||
})
|
|
||||||
|
|
||||||
return [panResponder.panHandlers, scaleValue, translateValue]
|
|
||||||
}
|
|
||||||
|
|
||||||
const getImageDimensionsByTranslate = (
|
|
||||||
translate: Position,
|
|
||||||
screen: {width: number; height: number},
|
|
||||||
): {width: number; height: number} => ({
|
|
||||||
width: screen.width - translate.x * 2,
|
|
||||||
height: screen.height - translate.y * 2,
|
|
||||||
})
|
|
||||||
|
|
||||||
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default usePanResponder
|
|
|
@ -10,6 +10,7 @@
|
||||||
|
|
||||||
import React, {
|
import React, {
|
||||||
ComponentType,
|
ComponentType,
|
||||||
|
createRef,
|
||||||
useCallback,
|
useCallback,
|
||||||
useRef,
|
useRef,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
@ -32,6 +33,7 @@ import ImageItem from './components/ImageItem/ImageItem'
|
||||||
import ImageDefaultHeader from './components/ImageDefaultHeader'
|
import ImageDefaultHeader from './components/ImageDefaultHeader'
|
||||||
|
|
||||||
import {ImageSource} from './@types'
|
import {ImageSource} from './@types'
|
||||||
|
import {ScrollView, GestureType} from 'react-native-gesture-handler'
|
||||||
import {Edge, SafeAreaView} from 'react-native-safe-area-context'
|
import {Edge, SafeAreaView} from 'react-native-safe-area-context'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -67,6 +69,8 @@ function ImageViewing({
|
||||||
FooterComponent,
|
FooterComponent,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const imageList = useRef<VirtualizedList<ImageSource>>(null)
|
const imageList = useRef<VirtualizedList<ImageSource>>(null)
|
||||||
|
const [isScaled, setIsScaled] = useState(false)
|
||||||
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
const [opacity, setOpacity] = useState(1)
|
const [opacity, setOpacity] = useState(1)
|
||||||
const [currentImageIndex, setImageIndex] = useState(imageIndex)
|
const [currentImageIndex, setImageIndex] = useState(imageIndex)
|
||||||
const [headerTranslate] = useState(
|
const [headerTranslate] = useState(
|
||||||
|
@ -115,10 +119,9 @@ function ImageViewing({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onZoom = (isScaled: boolean) => {
|
const onZoom = (nextIsScaled: boolean) => {
|
||||||
// @ts-ignore
|
toggleBarsVisible(!nextIsScaled)
|
||||||
imageList?.current?.setNativeProps({scrollEnabled: !isScaled})
|
setIsScaled(false)
|
||||||
toggleBarsVisible(!isScaled)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const edges = useMemo(() => {
|
const edges = useMemo(() => {
|
||||||
|
@ -134,6 +137,17 @@ function ImageViewing({
|
||||||
}
|
}
|
||||||
}, [imageList, imageIndex])
|
}, [imageList, imageIndex])
|
||||||
|
|
||||||
|
// This is a hack.
|
||||||
|
// RNGH doesn't have an easy way to express that pinch of individual items
|
||||||
|
// should "steal" all pinches from the scroll view. So we're keeping a ref
|
||||||
|
// to all pinch gestures so that we may give them to <ScrollView waitFor={...}>.
|
||||||
|
const [pinchGestureRefs] = useState(new Map())
|
||||||
|
for (let imageSrc of images) {
|
||||||
|
if (!pinchGestureRefs.get(imageSrc)) {
|
||||||
|
pinchGestureRefs.set(imageSrc, createRef<GestureType | undefined>())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
@ -163,6 +177,7 @@ function ImageViewing({
|
||||||
data={images}
|
data={images}
|
||||||
horizontal
|
horizontal
|
||||||
pagingEnabled
|
pagingEnabled
|
||||||
|
scrollEnabled={!isScaled || isDragging}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
getItem={(_, index) => images[index]}
|
getItem={(_, index) => images[index]}
|
||||||
|
@ -177,9 +192,26 @@ function ImageViewing({
|
||||||
onZoom={onZoom}
|
onZoom={onZoom}
|
||||||
imageSrc={imageSrc}
|
imageSrc={imageSrc}
|
||||||
onRequestClose={onRequestCloseEnhanced}
|
onRequestClose={onRequestCloseEnhanced}
|
||||||
|
pinchGestureRef={pinchGestureRefs.get(imageSrc)}
|
||||||
|
isScrollViewBeingDragged={isDragging}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
onMomentumScrollEnd={onScroll}
|
renderScrollComponent={props => (
|
||||||
|
<ScrollView
|
||||||
|
{...props}
|
||||||
|
waitFor={Array.from(pinchGestureRefs.values())}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
onScrollBeginDrag={() => {
|
||||||
|
setIsDragging(true)
|
||||||
|
}}
|
||||||
|
onScrollEndDrag={() => {
|
||||||
|
setIsDragging(false)
|
||||||
|
}}
|
||||||
|
onMomentumScrollEnd={e => {
|
||||||
|
setIsScaled(false)
|
||||||
|
onScroll(e)
|
||||||
|
}}
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
keyExtractor={(imageSrc, index) =>
|
keyExtractor={(imageSrc, index) =>
|
||||||
keyExtractor
|
keyExtractor
|
||||||
|
|
|
@ -0,0 +1,98 @@
|
||||||
|
import type {Position} from './@types'
|
||||||
|
|
||||||
|
export type TransformMatrix = [
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
]
|
||||||
|
|
||||||
|
// These are affine transforms. See explanation of every cell here:
|
||||||
|
// https://en.wikipedia.org/wiki/Transformation_matrix#/media/File:2D_affine_transformation_matrix.svg
|
||||||
|
|
||||||
|
export function createTransform(): TransformMatrix {
|
||||||
|
'worklet'
|
||||||
|
return [1, 0, 0, 0, 1, 0, 0, 0, 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyRounding(t: TransformMatrix) {
|
||||||
|
'worklet'
|
||||||
|
t[2] = Math.round(t[2])
|
||||||
|
t[5] = Math.round(t[5])
|
||||||
|
// For example: 0.985, 0.99, 0.995, then 1:
|
||||||
|
t[0] = Math.round(t[0] * 200) / 200
|
||||||
|
t[4] = Math.round(t[0] * 200) / 200
|
||||||
|
}
|
||||||
|
|
||||||
|
// We're using a limited subset (always scaling and translating while keeping aspect ratio) so
|
||||||
|
// we can assume the transform doesn't encode have skew, rotation, or non-uniform stretching.
|
||||||
|
|
||||||
|
// All write operations are applied in-place to avoid unnecessary allocations.
|
||||||
|
|
||||||
|
export function readTransform(t: TransformMatrix): [number, number, number] {
|
||||||
|
'worklet'
|
||||||
|
const scale = t[0]
|
||||||
|
const translateX = t[2]
|
||||||
|
const translateY = t[5]
|
||||||
|
return [translateX, translateY, scale]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prependTranslate(t: TransformMatrix, x: number, y: number) {
|
||||||
|
'worklet'
|
||||||
|
t[2] += t[0] * x + t[1] * y
|
||||||
|
t[5] += t[3] * x + t[4] * y
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prependScale(t: TransformMatrix, value: number) {
|
||||||
|
'worklet'
|
||||||
|
t[0] *= value
|
||||||
|
t[1] *= value
|
||||||
|
t[3] *= value
|
||||||
|
t[4] *= value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prependTransform(ta: TransformMatrix, tb: TransformMatrix) {
|
||||||
|
'worklet'
|
||||||
|
// In-place matrix multiplication.
|
||||||
|
const a00 = ta[0],
|
||||||
|
a01 = ta[1],
|
||||||
|
a02 = ta[2]
|
||||||
|
const a10 = ta[3],
|
||||||
|
a11 = ta[4],
|
||||||
|
a12 = ta[5]
|
||||||
|
const a20 = ta[6],
|
||||||
|
a21 = ta[7],
|
||||||
|
a22 = ta[8]
|
||||||
|
ta[0] = a00 * tb[0] + a01 * tb[3] + a02 * tb[6]
|
||||||
|
ta[1] = a00 * tb[1] + a01 * tb[4] + a02 * tb[7]
|
||||||
|
ta[2] = a00 * tb[2] + a01 * tb[5] + a02 * tb[8]
|
||||||
|
ta[3] = a10 * tb[0] + a11 * tb[3] + a12 * tb[6]
|
||||||
|
ta[4] = a10 * tb[1] + a11 * tb[4] + a12 * tb[7]
|
||||||
|
ta[5] = a10 * tb[2] + a11 * tb[5] + a12 * tb[8]
|
||||||
|
ta[6] = a20 * tb[0] + a21 * tb[3] + a22 * tb[6]
|
||||||
|
ta[7] = a20 * tb[1] + a21 * tb[4] + a22 * tb[7]
|
||||||
|
ta[8] = a20 * tb[2] + a21 * tb[5] + a22 * tb[8]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prependPan(t: TransformMatrix, translation: Position) {
|
||||||
|
'worklet'
|
||||||
|
prependTranslate(t, translation.x, translation.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prependPinch(
|
||||||
|
t: TransformMatrix,
|
||||||
|
scale: number,
|
||||||
|
origin: Position,
|
||||||
|
translation: Position,
|
||||||
|
) {
|
||||||
|
'worklet'
|
||||||
|
prependTranslate(t, translation.x, translation.y)
|
||||||
|
prependTranslate(t, origin.x, origin.y)
|
||||||
|
prependScale(t, scale)
|
||||||
|
prependTranslate(t, -origin.x, -origin.y)
|
||||||
|
}
|
|
@ -1,42 +0,0 @@
|
||||||
/**
|
|
||||||
* Copyright (c) JOB TODAY S.A. and its affiliates.
|
|
||||||
*
|
|
||||||
* This source code is licensed under the MIT license found in the
|
|
||||||
* LICENSE file in the root directory of this source tree.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {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 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'),
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in New Issue