tooot/src/screens/ImageViewer/hooks/usePanResponder.ts

408 lines
12 KiB
TypeScript

/**
* Copyright (c) JOB TODAY S.A. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import { useMemo, useEffect } from 'react'
import {
Animated,
Dimensions,
GestureResponderEvent,
GestureResponderHandlers,
NativeTouchEvent,
PanResponderGestureState
} from 'react-native'
import { Position } from '../@types'
import {
createPanResponder,
getDistanceBetweenTouches,
getImageTranslate,
getImageDimensionsByTranslate
} from '../utils'
const SCREEN = Dimensions.get('window')
const SCREEN_WIDTH = SCREEN.width
const SCREEN_HEIGHT = SCREEN.height
const MIN_DIMENSION = Math.min(SCREEN_WIDTH, SCREEN_HEIGHT)
const SCALE_MAX = 1
const DOUBLE_TAP_DELAY = 300
const OUT_BOUND_MULTIPLIER = 0.75
type Props = {
initialScale: number
initialTranslate: Position
onZoom: (isZoomed: boolean) => void
doubleTapToZoomEnabled: boolean
onLongPress: () => void
delayLongPress: number
onRequestClose: () => void
}
const usePanResponder = ({
initialScale,
initialTranslate,
onZoom,
doubleTapToZoomEnabled,
onLongPress,
delayLongPress,
onRequestClose
}: Props): Readonly<[
GestureResponderHandlers,
Animated.Value,
Animated.ValueXY
]> => {
let numberInitialTouches = 1
let initialTouches: NativeTouchEvent[] = []
let currentScale = initialScale
let currentTranslate = initialTranslate
let tmpScale = 0
let tmpTranslate: Position | null = null
let isDoubleTapPerformed = false
let lastTapTS: number | null = null
let timer: number | null = null
let longPressHandlerRef: number | null = null
const meaningfulShift = MIN_DIMENSION * 0.01
const scaleValue = new Animated.Value(initialScale)
const translateValue = new Animated.ValueXY(initialTranslate)
const imageDimensions = getImageDimensionsByTranslate(
initialTranslate,
SCREEN
)
const getBounds = (scale: number) => {
const scaledImageDimensions = {
width: imageDimensions.width * scale,
height: imageDimensions.height * scale
}
const translateDelta = getImageTranslate(scaledImageDimensions, SCREEN)
const left = initialTranslate.x - translateDelta.x
const right = left - (scaledImageDimensions.width - SCREEN.width)
const top = initialTranslate.y - translateDelta.y
const bottom = top - (scaledImageDimensions.height - SCREEN.height)
return [top, left, bottom, right]
}
const getTranslateInBounds = (translate: Position, scale: number) => {
const inBoundTranslate = { x: translate.x, y: translate.y }
const [topBound, leftBound, bottomBound, rightBound] = getBounds(scale)
if (translate.x > leftBound) {
inBoundTranslate.x = leftBound
} else if (translate.x < rightBound) {
inBoundTranslate.x = rightBound
}
if (translate.y > topBound) {
inBoundTranslate.y = topBound
} else if (translate.y < bottomBound) {
inBoundTranslate.y = bottomBound
}
return inBoundTranslate
}
const fitsScreenByWidth = () =>
imageDimensions.width * currentScale < SCREEN_WIDTH
const fitsScreenByHeight = () =>
imageDimensions.height * currentScale < SCREEN_HEIGHT
useEffect(() => {
scaleValue.addListener(({ value }) => {
if (typeof onZoom === 'function') {
onZoom(value !== initialScale)
}
})
return () => scaleValue.removeAllListeners()
})
const cancelLongPressHandle = () => {
longPressHandlerRef && clearTimeout(longPressHandlerRef)
}
const handlers = {
onGrant: (
_: GestureResponderEvent,
gestureState: PanResponderGestureState
) => {
numberInitialTouches = gestureState.numberActiveTouches
if (gestureState.numberActiveTouches > 1) return
longPressHandlerRef = setTimeout(onLongPress, delayLongPress)
},
onStart: (
event: GestureResponderEvent,
gestureState: PanResponderGestureState
) => {
initialTouches = event.nativeEvent.touches
numberInitialTouches = gestureState.numberActiveTouches
if (gestureState.numberActiveTouches > 1) return
const tapTS = Date.now()
!timer &&
(timer = setTimeout(() => onRequestClose(), DOUBLE_TAP_DELAY + 50))
// Handle double tap event by calculating diff between first and second taps timestamps
isDoubleTapPerformed = Boolean(
lastTapTS && tapTS - lastTapTS < DOUBLE_TAP_DELAY
)
if (doubleTapToZoomEnabled && isDoubleTapPerformed) {
clearTimeout(timer)
const isScaled = currentTranslate.x !== initialTranslate.x // currentScale !== initialScale;
const { pageX: touchX, pageY: touchY } = event.nativeEvent.touches[0]
const targetScale = SCALE_MAX
const nextScale = isScaled ? initialScale : targetScale
const nextTranslate = isScaled
? initialTranslate
: getTranslateInBounds(
{
x:
initialTranslate.x +
(SCREEN_WIDTH / 2 - touchX) * (targetScale / currentScale),
y:
initialTranslate.y +
(SCREEN_HEIGHT / 2 - -touchY) * (targetScale / currentScale)
},
targetScale
)
onZoom(!isScaled)
Animated.parallel(
[
Animated.timing(translateValue.x, {
toValue: nextTranslate.x,
duration: 300,
useNativeDriver: true
}),
Animated.timing(translateValue.y, {
toValue: nextTranslate.y,
duration: 300,
useNativeDriver: true
}),
Animated.timing(scaleValue, {
toValue: nextScale,
duration: 300,
useNativeDriver: true
})
],
{ stopTogether: false }
).start(() => {
currentScale = nextScale
currentTranslate = nextTranslate
})
lastTapTS = null
timer = null
} else {
lastTapTS = Date.now()
}
},
onMove: (
event: GestureResponderEvent,
gestureState: PanResponderGestureState
) => {
const { dx, dy } = gestureState
if (Math.abs(dx) >= meaningfulShift || Math.abs(dy) >= meaningfulShift) {
cancelLongPressHandle()
timer && clearTimeout(timer)
}
// Don't need to handle move because double tap in progress (was handled in onStart)
if (doubleTapToZoomEnabled && isDoubleTapPerformed) {
cancelLongPressHandle()
timer && clearTimeout(timer)
return
}
if (
numberInitialTouches === 1 &&
gestureState.numberActiveTouches === 2
) {
numberInitialTouches = 2
initialTouches = event.nativeEvent.touches
}
const isTapGesture =
numberInitialTouches == 1 && gestureState.numberActiveTouches === 1
const isPinchGesture =
numberInitialTouches === 2 && gestureState.numberActiveTouches === 2
if (isPinchGesture) {
cancelLongPressHandle()
timer && clearTimeout(timer)
const initialDistance = getDistanceBetweenTouches(initialTouches)
const currentDistance = getDistanceBetweenTouches(
event.nativeEvent.touches
)
let nextScale = (currentDistance / initialDistance) * currentScale
/**
* In case image is scaling smaller than initial size ->
* slow down this transition by applying OUT_BOUND_MULTIPLIER
*/
if (nextScale < initialScale) {
nextScale =
nextScale + (initialScale - nextScale) * OUT_BOUND_MULTIPLIER
}
/**
* In case image is scaling down -> move it in direction of initial position
*/
if (currentScale > initialScale && currentScale > nextScale) {
const k = (currentScale - initialScale) / (currentScale - nextScale)
const nextTranslateX =
nextScale < initialScale
? initialTranslate.x
: currentTranslate.x -
(currentTranslate.x - initialTranslate.x) / k
const nextTranslateY =
nextScale < initialScale
? initialTranslate.y
: currentTranslate.y -
(currentTranslate.y - initialTranslate.y) / k
translateValue.x.setValue(nextTranslateX)
translateValue.y.setValue(nextTranslateY)
tmpTranslate = { x: nextTranslateX, y: nextTranslateY }
}
scaleValue.setValue(nextScale)
tmpScale = nextScale
}
if (isTapGesture && currentScale > initialScale) {
const { x, y } = currentTranslate
const { dx, dy } = gestureState
const [topBound, leftBound, bottomBound, rightBound] = getBounds(
currentScale
)
let nextTranslateX = x + dx
let nextTranslateY = y + dy
if (nextTranslateX > leftBound) {
nextTranslateX =
nextTranslateX - (nextTranslateX - leftBound) * OUT_BOUND_MULTIPLIER
}
if (nextTranslateX < rightBound) {
nextTranslateX =
nextTranslateX -
(nextTranslateX - rightBound) * OUT_BOUND_MULTIPLIER
}
if (nextTranslateY > topBound) {
nextTranslateY =
nextTranslateY - (nextTranslateY - topBound) * OUT_BOUND_MULTIPLIER
}
if (nextTranslateY < bottomBound) {
nextTranslateY =
nextTranslateY -
(nextTranslateY - bottomBound) * OUT_BOUND_MULTIPLIER
}
if (fitsScreenByWidth()) {
nextTranslateX = x
}
if (fitsScreenByHeight()) {
nextTranslateY = y
}
translateValue.x.setValue(nextTranslateX)
translateValue.y.setValue(nextTranslateY)
tmpTranslate = { x: nextTranslateX, y: nextTranslateY }
}
},
onRelease: () => {
cancelLongPressHandle()
if (isDoubleTapPerformed) {
isDoubleTapPerformed = false
}
if (tmpScale > 0) {
if (tmpScale < initialScale || tmpScale > SCALE_MAX) {
tmpScale = tmpScale < initialScale ? initialScale : SCALE_MAX
Animated.timing(scaleValue, {
toValue: tmpScale,
duration: 100,
useNativeDriver: true
}).start()
}
currentScale = tmpScale
tmpScale = 0
}
if (tmpTranslate) {
const { x, y } = tmpTranslate
const [topBound, leftBound, bottomBound, rightBound] = getBounds(
currentScale
)
let nextTranslateX = x
let nextTranslateY = y
if (!fitsScreenByWidth()) {
if (nextTranslateX > leftBound) {
nextTranslateX = leftBound
} else if (nextTranslateX < rightBound) {
nextTranslateX = rightBound
}
}
if (!fitsScreenByHeight()) {
if (nextTranslateY > topBound) {
nextTranslateY = topBound
} else if (nextTranslateY < bottomBound) {
nextTranslateY = bottomBound
}
}
Animated.parallel([
Animated.timing(translateValue.x, {
toValue: nextTranslateX,
duration: 100,
useNativeDriver: true
}),
Animated.timing(translateValue.y, {
toValue: nextTranslateY,
duration: 100,
useNativeDriver: true
})
]).start()
currentTranslate = { x: nextTranslateX, y: nextTranslateY }
tmpTranslate = null
}
}
}
const panResponder = useMemo(() => createPanResponder(handlers), [handlers])
return [panResponder.panHandlers, scaleValue, translateValue]
}
export default usePanResponder