@likashefqet/react-native-image-zoom
Version:
A performant zoomable image written in Reanimated v2+ 🚀
336 lines (333 loc) • 12.3 kB
JavaScript
import { useCallback, useRef } from 'react';
import { Gesture } from 'react-native-gesture-handler';
import { Easing, runOnJS, useAnimatedStyle, useSharedValue, withDecay, withTiming } from 'react-native-reanimated';
import { clamp } from '../utils/clamp';
import { limits } from '../utils/limits';
import { ANIMATION_VALUE, ZOOM_TYPE } from '../types';
import { useAnimationEnd } from './useAnimationEnd';
import { useInteractionId } from './useInteractionId';
import { usePanGestureCount } from './usePanGestureCount';
import { sum } from '../utils/sum';
const withTimingConfig = {
easing: Easing.inOut(Easing.quad)
};
export const useGestures = ({
width,
height,
center,
minScale = 1,
maxScale = 5,
scale: scaleValue,
doubleTapScale = 3,
maxPanPointers = 2,
isPanEnabled = true,
isPinchEnabled = true,
isSingleTapEnabled = false,
isDoubleTapEnabled = false,
onInteractionStart,
onInteractionEnd,
onPinchStart,
onPinchEnd,
onPanStart,
onPanEnd,
onSingleTap = () => {},
onDoubleTap = () => {},
onProgrammaticZoom = () => {},
onResetAnimationEnd
}) => {
const isInteracting = useRef(false);
const isPinching = useRef(false);
const {
isPanning,
startPan,
endPan
} = usePanGestureCount();
const savedScale = useSharedValue(1);
const internalScaleValue = useSharedValue(1);
const scale = scaleValue ?? internalScaleValue;
const initialFocal = {
x: useSharedValue(0),
y: useSharedValue(0)
};
const savedFocal = {
x: useSharedValue(0),
y: useSharedValue(0)
};
const focal = {
x: useSharedValue(0),
y: useSharedValue(0)
};
const savedTranslate = {
x: useSharedValue(0),
y: useSharedValue(0)
};
const translate = {
x: useSharedValue(0),
y: useSharedValue(0)
};
const {
getInteractionId,
updateInteractionId
} = useInteractionId();
const {
onAnimationEnd
} = useAnimationEnd(onResetAnimationEnd);
const reset = useCallback(() => {
'worklet';
const interactionId = getInteractionId();
savedScale.value = 1;
const lastScaleValue = scale.value;
scale.value = withTiming(1, withTimingConfig, (...args) => onAnimationEnd(interactionId, ANIMATION_VALUE.SCALE, lastScaleValue, ...args));
initialFocal.x.value = 0;
initialFocal.y.value = 0;
savedFocal.x.value = 0;
savedFocal.y.value = 0;
const lastFocalXValue = focal.x.value;
focal.x.value = withTiming(0, withTimingConfig, (...args) => onAnimationEnd(interactionId, ANIMATION_VALUE.FOCAL_X, lastFocalXValue, ...args));
const lastFocalYValue = focal.y.value;
focal.y.value = withTiming(0, withTimingConfig, (...args) => onAnimationEnd(interactionId, ANIMATION_VALUE.FOCAL_Y, lastFocalYValue, ...args));
savedTranslate.x.value = 0;
savedTranslate.y.value = 0;
const lastTranslateXValue = translate.x.value;
translate.x.value = withTiming(0, withTimingConfig, (...args) => onAnimationEnd(interactionId, ANIMATION_VALUE.TRANSLATE_X, lastTranslateXValue, ...args));
const lastTranslateYValue = translate.y.value;
translate.y.value = withTiming(0, withTimingConfig, (...args) => onAnimationEnd(interactionId, ANIMATION_VALUE.TRANSLATE_Y, lastTranslateYValue, ...args));
}, [savedScale, scale, initialFocal.x, initialFocal.y, savedFocal.x, savedFocal.y, focal.x, focal.y, savedTranslate.x, savedTranslate.y, translate.x, translate.y, getInteractionId, onAnimationEnd]);
const moveIntoView = () => {
'worklet';
if (scale.value > 1) {
const rightLimit = limits.right(width, scale);
const leftLimit = -rightLimit;
const bottomLimit = limits.bottom(height, scale);
const topLimit = -bottomLimit;
const totalTranslateX = sum(translate.x, focal.x);
const totalTranslateY = sum(translate.y, focal.y);
if (totalTranslateX > rightLimit) {
translate.x.value = withTiming(rightLimit, withTimingConfig);
focal.x.value = withTiming(0, withTimingConfig);
} else if (totalTranslateX < leftLimit) {
translate.x.value = withTiming(leftLimit, withTimingConfig);
focal.x.value = withTiming(0, withTimingConfig);
}
if (totalTranslateY > bottomLimit) {
translate.y.value = withTiming(bottomLimit, withTimingConfig);
focal.y.value = withTiming(0, withTimingConfig);
} else if (totalTranslateY < topLimit) {
translate.y.value = withTiming(topLimit, withTimingConfig);
focal.y.value = withTiming(0, withTimingConfig);
}
} else {
reset();
}
};
const zoom = event => {
'worklet';
if (event.scale > 1) {
runOnJS(onProgrammaticZoom)(ZOOM_TYPE.ZOOM_IN);
scale.value = withTiming(event.scale, withTimingConfig);
focal.x.value = withTiming((center.x - event.x) * (event.scale - 1), withTimingConfig);
focal.y.value = withTiming((center.y - event.y) * (event.scale - 1), withTimingConfig);
} else {
runOnJS(onProgrammaticZoom)(ZOOM_TYPE.ZOOM_OUT);
reset();
}
};
const onInteractionStarted = () => {
if (!isInteracting.current) {
isInteracting.current = true;
onInteractionStart === null || onInteractionStart === void 0 || onInteractionStart();
updateInteractionId();
}
};
const onInteractionEnded = () => {
if (isInteracting.current && !isPinching.current && !isPanning()) {
if (isDoubleTapEnabled) {
moveIntoView();
} else {
reset();
}
isInteracting.current = false;
onInteractionEnd === null || onInteractionEnd === void 0 || onInteractionEnd();
}
};
const onPinchStarted = event => {
onInteractionStarted();
isPinching.current = true;
onPinchStart === null || onPinchStart === void 0 || onPinchStart(event);
};
const onPinchEnded = (...args) => {
isPinching.current = false;
onPinchEnd === null || onPinchEnd === void 0 || onPinchEnd(...args);
onInteractionEnded();
};
const onPanStarted = event => {
onInteractionStarted();
startPan();
onPanStart === null || onPanStart === void 0 || onPanStart(event);
};
const onPanEnded = (...args) => {
endPan();
onPanEnd === null || onPanEnd === void 0 || onPanEnd(...args);
onInteractionEnded();
};
const panWhilePinchingGesture = Gesture.Pan().enabled(isPanEnabled).averageTouches(true).enableTrackpadTwoFingerGesture(true).minPointers(2).maxPointers(maxPanPointers).onStart(event => {
runOnJS(onPanStarted)(event);
savedTranslate.x.value = translate.x.value;
savedTranslate.y.value = translate.y.value;
}).onUpdate(event => {
translate.x.value = savedTranslate.x.value + event.translationX;
translate.y.value = savedTranslate.y.value + event.translationY;
}).onEnd((event, success) => {
const rightLimit = limits.right(width, scale);
const leftLimit = -rightLimit;
const bottomLimit = limits.bottom(height, scale);
const topLimit = -bottomLimit;
if (scale.value > 1 && isDoubleTapEnabled) {
translate.x.value = withDecay({
velocity: event.velocityX,
velocityFactor: 0.6,
rubberBandEffect: true,
rubberBandFactor: 0.9,
clamp: [leftLimit - focal.x.value, rightLimit - focal.x.value]
}, () => {
if (event.velocityX >= event.velocityY) {
runOnJS(onPanEnded)(event, success);
}
});
translate.y.value = withDecay({
velocity: event.velocityY,
velocityFactor: 0.6,
rubberBandEffect: true,
rubberBandFactor: 0.9,
clamp: [topLimit - focal.y.value, bottomLimit - focal.y.value]
}, () => {
if (event.velocityY > event.velocityX) {
runOnJS(onPanEnded)(event, success);
}
});
} else {
runOnJS(onPanEnded)(event, success);
}
});
const panOnlyGesture = Gesture.Pan().enabled(isPanEnabled).averageTouches(true).enableTrackpadTwoFingerGesture(true).minPointers(1).maxPointers(1).onTouchesDown((_, manager) => {
if (scale.value <= 1) {
manager.fail();
}
}).onStart(event => {
runOnJS(onPanStarted)(event);
savedTranslate.x.value = translate.x.value;
savedTranslate.y.value = translate.y.value;
}).onUpdate(event => {
translate.x.value = savedTranslate.x.value + event.translationX;
translate.y.value = savedTranslate.y.value + event.translationY;
}).onEnd((event, success) => {
const rightLimit = limits.right(width, scale);
const leftLimit = -rightLimit;
const bottomLimit = limits.bottom(height, scale);
const topLimit = -bottomLimit;
if (scale.value > 1 && isDoubleTapEnabled) {
translate.x.value = withDecay({
velocity: event.velocityX,
velocityFactor: 0.6,
rubberBandEffect: true,
rubberBandFactor: 0.9,
clamp: [leftLimit - focal.x.value, rightLimit - focal.x.value]
}, () => {
if (event.velocityX >= event.velocityY) {
runOnJS(onPanEnded)(event, success);
}
});
translate.y.value = withDecay({
velocity: event.velocityY,
velocityFactor: 0.6,
rubberBandEffect: true,
rubberBandFactor: 0.9,
clamp: [topLimit - focal.y.value, bottomLimit - focal.y.value]
}, () => {
if (event.velocityY > event.velocityX) {
runOnJS(onPanEnded)(event, success);
}
});
} else {
runOnJS(onPanEnded)(event, success);
}
});
const pinchGesture = Gesture.Pinch().enabled(isPinchEnabled).onStart(event => {
runOnJS(onPinchStarted)(event);
savedScale.value = scale.value;
savedFocal.x.value = focal.x.value;
savedFocal.y.value = focal.y.value;
initialFocal.x.value = event.focalX;
initialFocal.y.value = event.focalY;
}).onUpdate(event => {
scale.value = clamp(savedScale.value * event.scale, minScale, maxScale);
focal.x.value = savedFocal.x.value + (center.x - initialFocal.x.value) * (scale.value - savedScale.value);
focal.y.value = savedFocal.y.value + (center.y - initialFocal.y.value) * (scale.value - savedScale.value);
}).onEnd((...args) => {
runOnJS(onPinchEnded)(...args);
});
const doubleTapGesture = Gesture.Tap().enabled(isDoubleTapEnabled).numberOfTaps(2).maxDuration(250).onStart(event => {
if (scale.value === 1) {
runOnJS(onDoubleTap)(ZOOM_TYPE.ZOOM_IN);
scale.value = withTiming(doubleTapScale, withTimingConfig);
focal.x.value = withTiming((center.x - event.x) * (doubleTapScale - 1), withTimingConfig);
focal.y.value = withTiming((center.y - event.y) * (doubleTapScale - 1), withTimingConfig);
} else {
runOnJS(onDoubleTap)(ZOOM_TYPE.ZOOM_OUT);
reset();
}
});
const singleTapGesture = Gesture.Tap().enabled(isSingleTapEnabled).numberOfTaps(1).maxDistance(24).onStart(event => {
runOnJS(onSingleTap)(event);
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [{
translateX: translate.x.value
}, {
translateY: translate.y.value
}, {
translateX: focal.x.value
}, {
translateY: focal.y.value
}, {
scale: scale.value
}]
}), [translate.x, translate.y, focal.x, focal.y, scale]);
const getInfo = () => {
const totalTranslateX = translate.x.value + focal.x.value;
const totalTranslateY = translate.y.value + focal.y.value;
return {
container: {
width,
height,
center
},
scaledSize: {
width: width * scale.value,
height: height * scale.value
},
visibleArea: {
x: Math.abs(totalTranslateX - width * (scale.value - 1) / 2),
y: Math.abs(totalTranslateY - height * (scale.value - 1) / 2),
width,
height
},
transformations: {
translateX: totalTranslateX,
translateY: totalTranslateY,
scale: scale.value
}
};
};
const pinchPanGestures = Gesture.Simultaneous(pinchGesture, panWhilePinchingGesture);
const tapGestures = Gesture.Exclusive(doubleTapGesture, singleTapGesture);
const gestures = isDoubleTapEnabled || isSingleTapEnabled ? Gesture.Race(pinchPanGestures, panOnlyGesture, tapGestures) : pinchPanGestures;
return {
gestures,
animatedStyle,
zoom,
reset,
getInfo
};
};
//# sourceMappingURL=useGestures.js.map