[WIP] Rewrite Android lightbox
This commit is contained in:
@@ -1,16 +1,7 @@
|
|||||||
/**
|
import React, {useCallback, useRef, useEffect, 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 {
|
import {
|
||||||
Animated,
|
View,
|
||||||
ScrollView,
|
|
||||||
Dimensions,
|
Dimensions,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
NativeScrollEvent,
|
NativeScrollEvent,
|
||||||
@@ -18,19 +9,24 @@ import {
|
|||||||
NativeMethodsMixin,
|
NativeMethodsMixin,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import {Image} from 'expo-image'
|
import {Image} from 'expo-image'
|
||||||
|
import Animated, {
|
||||||
|
useAnimatedStyle,
|
||||||
|
useDerivedValue,
|
||||||
|
useSharedValue,
|
||||||
|
} from 'react-native-reanimated'
|
||||||
|
import {
|
||||||
|
GestureDetector,
|
||||||
|
Gesture
|
||||||
|
} from 'react-native-gesture-handler'
|
||||||
|
|
||||||
|
const AnimatedImage = Animated.createAnimatedComponent(Image)
|
||||||
|
|
||||||
import useImageDimensions from '../../hooks/useImageDimensions'
|
import useImageDimensions from '../../hooks/useImageDimensions'
|
||||||
import usePanResponder from '../../hooks/usePanResponder'
|
|
||||||
|
|
||||||
import {getImageStyles, getImageTransform} from '../../utils'
|
|
||||||
import {ImageSource} from '../../@types'
|
import {ImageSource} from '../../@types'
|
||||||
import {ImageLoading} from './ImageLoading'
|
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 SCREEN_HEIGHT = SCREEN.height
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
imageSrc: ImageSource
|
imageSrc: ImageSource
|
||||||
@@ -42,7 +38,112 @@ type Props = {
|
|||||||
doubleTapToZoomEnabled?: boolean
|
doubleTapToZoomEnabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const AnimatedImage = Animated.createAnimatedComponent(Image)
|
type Matrix3 = readonly [
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number
|
||||||
|
];
|
||||||
|
|
||||||
|
function createTransform(): Matrix3 {
|
||||||
|
'worklet'
|
||||||
|
return [1, 0, 0, 0, 1, 0, 0, 0, 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function prependTranslate(t: Matrix3, x: number, y: number) {
|
||||||
|
"worklet";
|
||||||
|
t[2] += t[0] * x + t[1] * y;
|
||||||
|
t[5] += t[3] * x + t[4] * y;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prependScale(t: Matrix3, value: number) {
|
||||||
|
'worklet'
|
||||||
|
t[0] *= value;
|
||||||
|
t[1] *= value;
|
||||||
|
t[3] *= value;
|
||||||
|
t[4] *= value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prependTransform(ta: Matrix3, tb: Matrix3) {
|
||||||
|
"worklet";
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTransform(t): [number, number, number] {
|
||||||
|
'worklet'
|
||||||
|
const scale = t[0]
|
||||||
|
const translateX = t[2]
|
||||||
|
const translateY = t[5]
|
||||||
|
return [translateX, translateY, scale]
|
||||||
|
}
|
||||||
|
|
||||||
|
function prependPan(t, translation) {
|
||||||
|
'worklet'
|
||||||
|
prependTranslate(t, translation.x, translation.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prependPinch(t, scale, origin, translation) {
|
||||||
|
'worklet'
|
||||||
|
prependTranslate(t, translation.x, translation.y);
|
||||||
|
prependTranslate(t, origin.x, origin.y);
|
||||||
|
prependScale(t, scale);
|
||||||
|
prependTranslate(t, -origin.x, -origin.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStyle(t) {
|
||||||
|
"worklet";
|
||||||
|
const [translateX, translateY, scale] = readTransform(t)
|
||||||
|
return {
|
||||||
|
transform: [
|
||||||
|
{ translateX },
|
||||||
|
{ translateY },
|
||||||
|
{ scale }
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScaledDimensions(imageDimensions, scale) {
|
||||||
|
'worklet'
|
||||||
|
const imageAspect = imageDimensions.width / imageDimensions.height
|
||||||
|
const screenAspect = SCREEN.width / SCREEN.height
|
||||||
|
const isLandscape = imageAspect > screenAspect
|
||||||
|
if (isLandscape) {
|
||||||
|
return {
|
||||||
|
width: scale * SCREEN.width,
|
||||||
|
height: scale * SCREEN.width / imageAspect
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
width: scale * SCREEN.height * imageAspect,
|
||||||
|
height: scale * SCREEN.height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampTranslation(value, scaledSize, screenSize) {
|
||||||
|
'worklet'
|
||||||
|
const panDistance = Math.max(0, (scaledSize - screenSize) / 2)
|
||||||
|
const clampedValue = Math.min(Math.max(-panDistance, value), panDistance)
|
||||||
|
return clampedValue
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialTransform = createTransform();
|
||||||
|
|
||||||
const ImageItem = ({
|
const ImageItem = ({
|
||||||
imageSrc,
|
imageSrc,
|
||||||
@@ -53,104 +154,111 @@ const ImageItem = ({
|
|||||||
swipeToCloseEnabled = true,
|
swipeToCloseEnabled = true,
|
||||||
doubleTapToZoomEnabled = true,
|
doubleTapToZoomEnabled = true,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const imageContainer = useRef<ScrollView & NativeMethodsMixin>(null)
|
|
||||||
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 pinchOrigin = useSharedValue({ x: 0, y: 0 })
|
||||||
const [isLoaded, setLoadEnd] = useState(false)
|
const pinchScale = useSharedValue(1)
|
||||||
|
const pinchTranslation = useSharedValue({ x: 0, y: 0 })
|
||||||
|
const panTranslation = useSharedValue({ x: 0, y: 0 })
|
||||||
|
|
||||||
const onLoaded = useCallback(() => setLoadEnd(true), [])
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
const onZoomPerformed = useCallback(
|
let t = createTransform();
|
||||||
(isZoomed: boolean) => {
|
prependPan(t, panTranslation.value);
|
||||||
onZoom(isZoomed)
|
prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value);
|
||||||
if (imageContainer?.current) {
|
prependTransform(t, committedTransform.value);
|
||||||
imageContainer.current.setNativeProps({
|
return toStyle(t)
|
||||||
scrollEnabled: !isZoomed,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onZoom],
|
|
||||||
)
|
|
||||||
|
|
||||||
const onLongPressHandler = useCallback(() => {
|
|
||||||
onLongPress(imageSrc)
|
|
||||||
}, [imageSrc, onLongPress])
|
|
||||||
|
|
||||||
const [panHandlers, scaleValue, translateValue] = usePanResponder({
|
|
||||||
initialScale: scale || 1,
|
|
||||||
initialTranslate: translate || {x: 0, y: 0},
|
|
||||||
onZoom: onZoomPerformed,
|
|
||||||
doubleTapToZoomEnabled,
|
|
||||||
onLongPress: onLongPressHandler,
|
|
||||||
delayLongPress,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const imagesStyles = getImageStyles(
|
function getPanLimitAdjustment(nextPanTranslation) {
|
||||||
imageDimensions,
|
'worklet';
|
||||||
translateValue,
|
if (!imageDimensions) {
|
||||||
scaleValue,
|
return [0, 0]
|
||||||
)
|
|
||||||
const imageOpacity = scrollValueY.interpolate({
|
|
||||||
inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
|
|
||||||
outputRange: [0.7, 1, 0.7],
|
|
||||||
})
|
|
||||||
const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity}
|
|
||||||
|
|
||||||
const onScrollEndDrag = ({
|
|
||||||
nativeEvent,
|
|
||||||
}: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
||||||
const velocityY = nativeEvent?.velocity?.y ?? 0
|
|
||||||
const offsetY = nativeEvent?.contentOffset?.y ?? 0
|
|
||||||
|
|
||||||
if (
|
|
||||||
(Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY &&
|
|
||||||
offsetY > SWIPE_CLOSE_OFFSET) ||
|
|
||||||
offsetY > SCREEN_HEIGHT / 2
|
|
||||||
) {
|
|
||||||
onRequestClose()
|
|
||||||
}
|
}
|
||||||
|
let t = createTransform();
|
||||||
|
prependPan(t, nextPanTranslation)
|
||||||
|
prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value);
|
||||||
|
prependTransform(t, committedTransform.value)
|
||||||
|
const [nextTranslateX, nextTranslateY, nextScale] = readTransform(t)
|
||||||
|
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>) => {
|
const pinch = Gesture.Pinch()
|
||||||
const offsetY = nativeEvent?.contentOffset?.y ?? 0
|
.onStart((e) => {
|
||||||
|
pinchOrigin.value = {
|
||||||
|
x: e.focalX - SCREEN.width/2,
|
||||||
|
y: e.focalY - SCREEN.height/2
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.onChange((e) => {
|
||||||
|
const [,,committedScale] = readTransform(committedTransform.value)
|
||||||
|
const nextScale = Math.max(e.scale, 1 / committedScale);
|
||||||
|
pinchScale.value = nextScale
|
||||||
|
const [dx, dy] = getPanLimitAdjustment(panTranslation.value)
|
||||||
|
if (dx !== 0 || dy !== 0) {
|
||||||
|
pinchTranslation.value = {
|
||||||
|
x: pinchTranslation.value.x + dx,
|
||||||
|
y: pinchTranslation.value.y + dy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.onEnd(() => {
|
||||||
|
let t = createTransform();
|
||||||
|
prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value)
|
||||||
|
prependTransform(t, committedTransform.value);
|
||||||
|
committedTransform.value = t
|
||||||
|
pinchScale.value = 1;
|
||||||
|
pinchOrigin.value = { x: 0, y: 0 };
|
||||||
|
pinchTranslation.value = { x: 0, y: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
scrollValueY.setValue(offsetY)
|
const pan = Gesture.Pan()
|
||||||
}
|
.averageTouches(true)
|
||||||
|
.onChange((e) => {
|
||||||
|
if (!imageDimensions) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextPanTranslation = { x: e.translationX, y: e.translationY }
|
||||||
|
const [dx, dy] = getPanLimitAdjustment(nextPanTranslation)
|
||||||
|
nextPanTranslation.x += dx
|
||||||
|
nextPanTranslation.y += dy
|
||||||
|
panTranslation.value = nextPanTranslation;
|
||||||
|
})
|
||||||
|
.onEnd(() => {
|
||||||
|
let t = createTransform();
|
||||||
|
prependPan(t, panTranslation.value)
|
||||||
|
prependTransform(t, committedTransform.value);
|
||||||
|
committedTransform.value = t
|
||||||
|
panTranslation.value = { x: 0, y: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<View
|
||||||
ref={imageContainer}
|
style={styles.container}>
|
||||||
style={styles.listItem}
|
<GestureDetector gesture={Gesture.Simultaneous(pinch, pan)}>
|
||||||
pagingEnabled
|
<AnimatedImage
|
||||||
nestedScrollEnabled
|
source={imageSrc}
|
||||||
showsHorizontalScrollIndicator={false}
|
resizeMode="contain"
|
||||||
showsVerticalScrollIndicator={false}
|
style={[styles.image, animatedStyle]}
|
||||||
contentContainerStyle={styles.imageScrollContainer}
|
accessibilityLabel={imageSrc.alt}
|
||||||
scrollEnabled={swipeToCloseEnabled}
|
accessibilityHint=""
|
||||||
{...(swipeToCloseEnabled && {
|
/>
|
||||||
onScroll,
|
</GestureDetector>
|
||||||
onScrollEndDrag,
|
</View>
|
||||||
})}>
|
)
|
||||||
<AnimatedImage
|
|
||||||
{...panHandlers}
|
|
||||||
source={imageSrc}
|
|
||||||
style={imageStylesWithOpacity}
|
|
||||||
onLoad={onLoaded}
|
|
||||||
accessibilityLabel={imageSrc.alt}
|
|
||||||
accessibilityHint=""
|
|
||||||
/>
|
|
||||||
{(!isLoaded || !imageDimensions) && <ImageLoading />}
|
|
||||||
</ScrollView>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
listItem: {
|
container: {
|
||||||
width: SCREEN_WIDTH,
|
width: SCREEN.width,
|
||||||
height: SCREEN_HEIGHT,
|
height: SCREEN.height,
|
||||||
},
|
},
|
||||||
imageScrollContainer: {
|
image: {
|
||||||
height: SCREEN_HEIGHT * 2,
|
flex: 1,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,431 +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 {
|
|
||||||
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 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
|
|
||||||
doubleTapToZoomEnabled: boolean
|
|
||||||
onLongPress: () => void
|
|
||||||
delayLongPress: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const usePanResponder = ({
|
|
||||||
initialScale,
|
|
||||||
initialTranslate,
|
|
||||||
onZoom,
|
|
||||||
doubleTapToZoomEnabled,
|
|
||||||
onLongPress,
|
|
||||||
delayLongPress,
|
|
||||||
}: Props): Readonly<
|
|
||||||
[GestureResponderHandlers, Animated.Value, Animated.ValueXY]
|
|
||||||
> => {
|
|
||||||
let numberInitialTouches = 1
|
|
||||||
let initialTouches: NativeTouchEvent[] = []
|
|
||||||
let currentScale = initialScale
|
|
||||||
let currentTranslate = initialTranslate
|
|
||||||
let tmpScale = 0
|
|
||||||
let tmpTranslate: Position | null = null
|
|
||||||
let isDoubleTapPerformed = false
|
|
||||||
let lastTapTS: number | null = null
|
|
||||||
let longPressHandlerRef: NodeJS.Timeout | 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 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 cancelLongPressHandle = () => {
|
|
||||||
longPressHandlerRef && clearTimeout(longPressHandlerRef)
|
|
||||||
}
|
|
||||||
|
|
||||||
const panResponder = PanResponder.create({
|
|
||||||
onStartShouldSetPanResponder: () => true,
|
|
||||||
onStartShouldSetPanResponderCapture: () => true,
|
|
||||||
onMoveShouldSetPanResponder: () => true,
|
|
||||||
onMoveShouldSetPanResponderCapture: () => true,
|
|
||||||
onPanResponderGrant: (
|
|
||||||
_: GestureResponderEvent,
|
|
||||||
gestureState: PanResponderGestureState,
|
|
||||||
) => {
|
|
||||||
numberInitialTouches = gestureState.numberActiveTouches
|
|
||||||
|
|
||||||
if (gestureState.numberActiveTouches > 1) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
longPressHandlerRef = setTimeout(onLongPress, delayLongPress)
|
|
||||||
},
|
|
||||||
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 (doubleTapToZoomEnabled && 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,
|
|
||||||
) => {
|
|
||||||
const {dx, dy} = gestureState
|
|
||||||
|
|
||||||
if (Math.abs(dx) >= meaningfulShift || Math.abs(dy) >= meaningfulShift) {
|
|
||||||
cancelLongPressHandle()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't need to handle move because double tap in progress (was handled in onStart)
|
|
||||||
if (doubleTapToZoomEnabled && isDoubleTapPerformed) {
|
|
||||||
cancelLongPressHandle()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
numberInitialTouches === 1 &&
|
|
||||||
gestureState.numberActiveTouches === 2
|
|
||||||
) {
|
|
||||||
numberInitialTouches = 2
|
|
||||||
initialTouches = event.nativeEvent.touches
|
|
||||||
}
|
|
||||||
|
|
||||||
const isTapGesture =
|
|
||||||
numberInitialTouches === 1 && gestureState.numberActiveTouches === 1
|
|
||||||
const isPinchGesture =
|
|
||||||
numberInitialTouches === 2 && gestureState.numberActiveTouches === 2
|
|
||||||
|
|
||||||
if (isPinchGesture) {
|
|
||||||
cancelLongPressHandle()
|
|
||||||
|
|
||||||
const initialDistance = getDistanceBetweenTouches(initialTouches)
|
|
||||||
const currentDistance = getDistanceBetweenTouches(
|
|
||||||
event.nativeEvent.touches,
|
|
||||||
)
|
|
||||||
|
|
||||||
let nextScale = (currentDistance / initialDistance) * currentScale
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In case image is scaling smaller than initial size ->
|
|
||||||
* slow down this transition by applying OUT_BOUND_MULTIPLIER
|
|
||||||
*/
|
|
||||||
if (nextScale < initialScale) {
|
|
||||||
nextScale =
|
|
||||||
nextScale + (initialScale - nextScale) * OUT_BOUND_MULTIPLIER
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In case image is scaling down -> move it in direction of initial position
|
|
||||||
*/
|
|
||||||
if (currentScale > initialScale && currentScale > nextScale) {
|
|
||||||
const k = (currentScale - initialScale) / (currentScale - nextScale)
|
|
||||||
|
|
||||||
const nextTranslateX =
|
|
||||||
nextScale < initialScale
|
|
||||||
? initialTranslate.x
|
|
||||||
: currentTranslate.x -
|
|
||||||
(currentTranslate.x - initialTranslate.x) / k
|
|
||||||
|
|
||||||
const nextTranslateY =
|
|
||||||
nextScale < initialScale
|
|
||||||
? initialTranslate.y
|
|
||||||
: currentTranslate.y -
|
|
||||||
(currentTranslate.y - initialTranslate.y) / k
|
|
||||||
|
|
||||||
translateValue.x.setValue(nextTranslateX)
|
|
||||||
translateValue.y.setValue(nextTranslateY)
|
|
||||||
|
|
||||||
tmpTranslate = {x: nextTranslateX, y: nextTranslateY}
|
|
||||||
}
|
|
||||||
|
|
||||||
scaleValue.setValue(nextScale)
|
|
||||||
tmpScale = nextScale
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isTapGesture && currentScale > initialScale) {
|
|
||||||
const {x, y} = currentTranslate
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
|
||||||
const {dx, dy} = gestureState
|
|
||||||
const [topBound, leftBound, bottomBound, rightBound] =
|
|
||||||
getBounds(currentScale)
|
|
||||||
|
|
||||||
let nextTranslateX = x + dx
|
|
||||||
let nextTranslateY = y + dy
|
|
||||||
|
|
||||||
if (nextTranslateX > leftBound) {
|
|
||||||
nextTranslateX =
|
|
||||||
nextTranslateX - (nextTranslateX - leftBound) * OUT_BOUND_MULTIPLIER
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextTranslateX < rightBound) {
|
|
||||||
nextTranslateX =
|
|
||||||
nextTranslateX -
|
|
||||||
(nextTranslateX - rightBound) * OUT_BOUND_MULTIPLIER
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextTranslateY > topBound) {
|
|
||||||
nextTranslateY =
|
|
||||||
nextTranslateY - (nextTranslateY - topBound) * OUT_BOUND_MULTIPLIER
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextTranslateY < bottomBound) {
|
|
||||||
nextTranslateY =
|
|
||||||
nextTranslateY -
|
|
||||||
(nextTranslateY - bottomBound) * OUT_BOUND_MULTIPLIER
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fitsScreenByWidth()) {
|
|
||||||
nextTranslateX = x
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fitsScreenByHeight()) {
|
|
||||||
nextTranslateY = y
|
|
||||||
}
|
|
||||||
|
|
||||||
translateValue.x.setValue(nextTranslateX)
|
|
||||||
translateValue.y.setValue(nextTranslateY)
|
|
||||||
|
|
||||||
tmpTranslate = {x: nextTranslateX, y: nextTranslateY}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onPanResponderRelease: () => {
|
|
||||||
cancelLongPressHandle()
|
|
||||||
|
|
||||||
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]
|
|
||||||
}
|
|
||||||
|
|
||||||
export default usePanResponder
|
|
||||||
@@ -129,40 +129,15 @@ function ImageViewing({
|
|||||||
<ImageDefaultHeader onRequestClose={onRequestCloseEnhanced} />
|
<ImageDefaultHeader onRequestClose={onRequestCloseEnhanced} />
|
||||||
)}
|
)}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
<VirtualizedList
|
{/* TODO: show gallery again */}
|
||||||
ref={imageList}
|
<ImageItem
|
||||||
data={images}
|
onZoom={onZoom}
|
||||||
horizontal
|
imageSrc={images[0]}
|
||||||
pagingEnabled
|
onRequestClose={onRequestCloseEnhanced}
|
||||||
showsHorizontalScrollIndicator={false}
|
onLongPress={onLongPress}
|
||||||
showsVerticalScrollIndicator={false}
|
delayLongPress={delayLongPress}
|
||||||
getItem={(_, index) => images[index]}
|
swipeToCloseEnabled={swipeToCloseEnabled}
|
||||||
getItemCount={() => images.length}
|
doubleTapToZoomEnabled={doubleTapToZoomEnabled}
|
||||||
getItemLayout={(_, index) => ({
|
|
||||||
length: SCREEN_WIDTH,
|
|
||||||
offset: SCREEN_WIDTH * index,
|
|
||||||
index,
|
|
||||||
})}
|
|
||||||
renderItem={({item: imageSrc}) => (
|
|
||||||
<ImageItem
|
|
||||||
onZoom={onZoom}
|
|
||||||
imageSrc={imageSrc}
|
|
||||||
onRequestClose={onRequestCloseEnhanced}
|
|
||||||
onLongPress={onLongPress}
|
|
||||||
delayLongPress={delayLongPress}
|
|
||||||
swipeToCloseEnabled={swipeToCloseEnabled}
|
|
||||||
doubleTapToZoomEnabled={doubleTapToZoomEnabled}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
onMomentumScrollEnd={onScroll}
|
|
||||||
//@ts-ignore
|
|
||||||
keyExtractor={(imageSrc, index) =>
|
|
||||||
keyExtractor
|
|
||||||
? keyExtractor(imageSrc, index)
|
|
||||||
: typeof imageSrc === 'number'
|
|
||||||
? `${imageSrc}`
|
|
||||||
: imageSrc.uri
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
{typeof FooterComponent !== 'undefined' && (
|
{typeof FooterComponent !== 'undefined' && (
|
||||||
<Animated.View style={[styles.footer, {transform: footerTransform}]}>
|
<Animated.View style={[styles.footer, {transform: footerTransform}]}>
|
||||||
|
|||||||
Reference in New Issue
Block a user