react-native-media-viewing
Version:
React Native modal component for viewing images and video as a sliding gallery
274 lines (273 loc) • 12.5 kB
JavaScript
/**
* 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, } from "react-native";
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 = 2;
const DOUBLE_TAP_DELAY = 300;
const OUT_BOUND_MULTIPLIER = 0.75;
const usePanResponder = ({ initialScale, initialTranslate, onZoom, doubleTapToZoomEnabled, onLongPress, delayLongPress, }) => {
let numberInitialTouches = 1;
let initialTouches = [];
let currentScale = initialScale;
let currentTranslate = initialTranslate;
let tmpScale = 0;
let tmpTranslate = null;
let isDoubleTapPerformed = false;
let lastTapTS = null;
let longPressHandlerRef = 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) => {
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, scale) => {
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: (_, gestureState) => {
numberInitialTouches = gestureState.numberActiveTouches;
if (gestureState.numberActiveTouches > 1)
return;
longPressHandlerRef = setTimeout(onLongPress, delayLongPress);
},
onStart: (event, gestureState) => {
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) {
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;
}
else {
lastTapTS = Date.now();
}
},
onMove: (event, gestureState) => {
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;
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;