Rewrite Android lightbox (#1624)

zio/stable
dan 2023-10-06 03:54:36 +01:00 committed by GitHub
parent 8366fe2c4a
commit 64153067e3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 540 additions and 598 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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) => {

View File

@ -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

View File

@ -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

View File

@ -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)
}

View File

@ -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'),
}
}