UNPKG

@likashefqet/react-native-image-zoom

Version:

A performant zoomable image written in Reanimated v2+ 🚀

336 lines (333 loc) • 12.3 kB
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