import React, { useImperativeHandle } from 'react'; import { StyleSheet, View, type LayoutChangeEvent } from 'react-native'; import Animated, { useAnimatedStyle, useDerivedValue, useSharedValue, withTiming, } from 'react-native-reanimated'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { clamp } from '../../commons/utils/clamp'; import { useVector } from '../../commons/hooks/useVector'; import { getMaxScale } from '../../commons/utils/getMaxScale'; import { useSizeVector } from '../../commons/hooks/useSizeVector'; import { usePanCommons } from '../../commons/hooks/usePanCommons'; import { usePinchCommons } from '../../commons/hooks/usePinchCommons'; import { useDoubleTapCommons } from '../../commons/hooks/useDoubleTapCommons'; import { getVisibleRect as getRect } from '../../commons/utils/getVisibleRect'; import withResumableValidation from '../../commons/hoc/withResumableValidation'; import type { BoundsFuction, CommonTransformState, CommonZoomState, Rect, Vector, } from '../../commons/types'; import type { ResumableZoomProps, ResumableZoomRefType } from './types'; type ResumableZoomPropsWithRef = ResumableZoomProps & { reference?: React.ForwardedRef; }; const ResumableZoom: React.FC = (props) => { const { reference, children, style, extendGestures = false, decay = true, tapsEnabled = true, panEnabled = true, pinchEnabled = true, minScale = 1, maxScale: userMaxScale = 6, panMode = 'clamp', scaleMode = 'bounce', pinchMode = 'clamp', allowPinchPanning = true, longPressDuration = 500, onTap, onLongPress, onUpdate, onGestureEnd, onSwipe, onPinchStart: onUserPinchStart, onPinchEnd: onUserPinchEnd, onPanStart: onUserPanStart, onPanEnd: onUserPanEnd, onOverPanning, } = props; const rootSize = useSizeVector(1, 1); const childSize = useSizeVector(1, 1); const extendedSize = useSizeVector(1, 1); const translate = useVector(0, 0); const offset = useVector(0, 0); const scale = useSharedValue(minScale); const scaleOffset = useSharedValue(minScale); const maxScale = useDerivedValue(() => { if (typeof userMaxScale === 'object') { return getMaxScale( { width: childSize.width.value, height: childSize.height.value }, userMaxScale ); } return userMaxScale; }, [userMaxScale, childSize]); useDerivedValue(() => { extendedSize.width.value = extendGestures ? Math.max(rootSize.width.value, childSize.width.value) : childSize.width.value; extendedSize.height.value = extendGestures ? Math.max(rootSize.height.value, childSize.height.value) : childSize.height.value; }, [extendGestures, rootSize, childSize]); const boundsFn: BoundsFuction = (optionalScale) => { 'worklet'; const actualScale = optionalScale ?? scale.value; const { width: cWidth, height: cHeight } = childSize; const { width: rWidth, height: rHeight } = rootSize; const boundX = Math.max(0, cWidth.value * actualScale - rWidth.value) / 2; const boundY = Math.max(0, cHeight.value * actualScale - rHeight.value) / 2; return { x: boundX, y: boundY }; }; const set = (toX: number, toY: number, toScale: number, animate: boolean) => { 'worklet'; translate.x.value = animate ? withTiming(toX) : toX; translate.y.value = animate ? withTiming(toY) : toY; scale.value = animate ? withTiming(toScale) : toScale; scaleOffset.value = toScale; }; useDerivedValue(() => { onUpdate?.({ containerSize: { width: rootSize.width.value, height: rootSize.height.value, }, childSize: { width: childSize.width.value, height: childSize.height.value, }, maxScale: maxScale.value, translateX: translate.x.value, translateY: translate.y.value, scale: scale.value, }); }, [rootSize, childSize, translate, maxScale, scale]); const { gesturesEnabled, onTouchesDown, onTouchesMove, onTouchesUp, onPinchStart, onPinchUpdate, onPinchEnd, } = usePinchCommons({ container: extendedSize, translate, offset, scale, scaleOffset, minScale, maxScale, allowPinchPanning, scaleMode, pinchMode, boundFn: boundsFn, userCallbacks: { onGestureEnd, onPinchStart: onUserPinchStart, onPinchEnd: onUserPinchEnd, }, }); const { onPanStart, onPanChange, onPanEnd } = usePanCommons({ container: extendedSize, translate, offset, panMode, boundFn: boundsFn, decay, userCallbacks: { onSwipe, onGestureEnd, onPanStart: onUserPanStart, onPanEnd: onUserPanEnd, onOverPanning, }, }); const { onDoubleTapStart, onDoubleTapEnd, enablePanGestureByDoubleTap } = useDoubleTapCommons({ container: extendedSize, translate, scale, minScale, maxScale, scaleOffset, boundsFn: boundsFn, onGestureEnd, }); const pinch = Gesture.Pinch() .withTestId('pinch') .enabled(pinchEnabled) .manualActivation(true) .onTouchesDown(onTouchesDown) .onTouchesMove(onTouchesMove) .onTouchesUp(onTouchesUp) .onStart(onPinchStart) .onUpdate(onPinchUpdate) .onEnd(onPinchEnd); const pan = Gesture.Pan() .withTestId('pan') .enabled(panEnabled && gesturesEnabled && enablePanGestureByDoubleTap) .maxPointers(1) .onStart(onPanStart) .onChange(onPanChange) .onEnd(onPanEnd); const tap = Gesture.Tap() .withTestId('tap') .enabled(tapsEnabled && gesturesEnabled) .maxDuration(250) .numberOfTaps(1) .runOnJS(true) .onEnd((e) => onTap?.(e)); const doubleTap = Gesture.Tap() .withTestId('doubleTap') .enabled(tapsEnabled && gesturesEnabled) .maxDuration(250) .numberOfTaps(2) .onStart(onDoubleTapStart) .onEnd(onDoubleTapEnd); const longPress = Gesture.LongPress() .withTestId('longPress') .enabled(gesturesEnabled) .minDuration(longPressDuration) .runOnJS(true) .onStart((e) => onLongPress?.(e)); const measureRoot = (e: LayoutChangeEvent) => { rootSize.width.value = e.nativeEvent.layout.width; rootSize.height.value = e.nativeEvent.layout.height; }; const measureChild = (e: LayoutChangeEvent) => { childSize.width.value = e.nativeEvent.layout.width; childSize.height.value = e.nativeEvent.layout.height; }; const detectorStyle = useAnimatedStyle(() => { return { width: extendedSize.width.value, height: extendedSize.height.value, transform: [ { translateX: translate.x.value }, { translateY: translate.y.value }, { scale: scale.value }, ], }; }, [extendedSize, translate, scale]); const getState = (): CommonZoomState => { return { containerSize: { width: rootSize.width.value, height: rootSize.height.value, }, childSize: { width: childSize.width.value, height: childSize.height.value, }, maxScale: maxScale.value, translateX: translate.x.value, translateY: translate.y.value, scale: scale.value, }; }; const setState = (state: CommonTransformState, animate = true) => { const toScale = clamp(state.scale, minScale, maxScale.value); const { x: boundX, y: boundY } = boundsFn(toScale); const toX = clamp(state.translateX, -1 * boundX, boundX); const toY = clamp(state.translateY, -1 * boundY, boundY); set(toX, toY, toScale, animate); }; const getVisibleRect = (): Rect => { return getRect({ scale: scale.value, itemSize: { width: childSize.width.value, height: childSize.height.value, }, containerSize: { width: rootSize.width.value, height: rootSize.height.value, }, translation: { x: translate.x.value, y: translate.y.value }, }); }; const zoom = (newScale: number, position?: Vector) => { const toScale = clamp(newScale, minScale, maxScale.value); let focal = position; if (focal !== undefined) { focal = { x: clamp(focal.x, 0, childSize.width.value), y: clamp(focal.y, 0, childSize.height.value), }; } else { const frame = getVisibleRect(); focal = { x: frame.x + frame.width / 2, y: frame.y + frame.height / 2, }; } const centerX = childSize.width.value / 2; const centerY = childSize.height.value / 2; const originX = focal.x - centerX; const originY = focal.y - centerY; const signedDistanceCenterX = centerX - focal.x; const signedDistanceCenterY = centerY - focal.y; const translateX = signedDistanceCenterX + (originX - originX * toScale); const translateY = signedDistanceCenterY + (originY - originY * toScale); const { x: boundX, y: boundY } = boundsFn(toScale); const toX = clamp(translateX, -1 * boundX, boundX); const toY = clamp(translateY, -1 * boundY, boundY); set(toX, toY, toScale, true); }; useImperativeHandle(reference, () => ({ reset: (animate = true) => set(0, 0, minScale, animate), getState: getState, setTransformState: setState, zoom: zoom, getVisibleRect: getVisibleRect, })); const composedTap = Gesture.Exclusive(doubleTap, tap, longPress); const composedGesture = Gesture.Race(pinch, pan, composedTap); return ( {children} ); }; const styles = StyleSheet.create({ flex: { flex: 1, }, center: { justifyContent: 'center', alignItems: 'center', }, }); export default withResumableValidation< ResumableZoomRefType, ResumableZoomProps >(ResumableZoom);