UNPKG

@likashefqet/react-native-image-zoom

Version:

A performant zoomable image written in Reanimated v2+ 🚀

459 lines (431 loc) • 13.3 kB
import { useCallback, useRef } from 'react'; import { Gesture } from 'react-native-gesture-handler'; import { Easing, runOnJS, useAnimatedStyle, useSharedValue, withDecay, withTiming, WithTimingConfig, } from 'react-native-reanimated'; import { clamp } from '../utils/clamp'; import { limits } from '../utils/limits'; import { ANIMATION_VALUE, ZOOM_TYPE } from '../types'; import type { GetInfoCallback, OnPanEndCallback, OnPanStartCallback, OnPinchEndCallback, OnPinchStartCallback, ProgrammaticZoomCallback, ZoomableUseGesturesProps, } from '../types'; import { useAnimationEnd } from './useAnimationEnd'; import { useInteractionId } from './useInteractionId'; import { usePanGestureCount } from './usePanGestureCount'; import { sum } from '../utils/sum'; const withTimingConfig: 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, }: ZoomableUseGesturesProps) => { 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: ProgrammaticZoomCallback = (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?.(); updateInteractionId(); } }; const onInteractionEnded = () => { if (isInteracting.current && !isPinching.current && !isPanning()) { if (isDoubleTapEnabled) { moveIntoView(); } else { reset(); } isInteracting.current = false; onInteractionEnd?.(); } }; const onPinchStarted: OnPinchStartCallback = (event) => { onInteractionStarted(); isPinching.current = true; onPinchStart?.(event); }; const onPinchEnded: OnPinchEndCallback = (...args) => { isPinching.current = false; onPinchEnd?.(...args); onInteractionEnded(); }; const onPanStarted: OnPanStartCallback = (event) => { onInteractionStarted(); startPan(); onPanStart?.(event); }; const onPanEnded: OnPanEndCallback = (...args) => { endPan(); 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: GetInfoCallback = () => { 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 }; };