import React from 'react'; import Animated, { measure, runOnJS, useAnimatedRef, useAnimatedStyle, useDerivedValue, useSharedValue, withTiming, } from 'react-native-reanimated'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { useVector } from '../../commons/hooks/useVector'; import { useSizeVector } from '../../commons/hooks/useSizeVector'; import { resizeToAspectRatio } from '../../commons/utils/resizeToAspectRatio'; import withSnapbackValidation from '../../commons/hoc/withSnapbackValidation'; import type { SnapBackZoomProps, SnapbackZoomState } from './types'; const DEFAULT_HITSLOP = { vertical: 0, horizontal: 0 }; const SnapbackZoom: React.FC = ({ children, hitSlop = DEFAULT_HITSLOP, resizeConfig, timingConfig, gesturesEnabled = true, longPressDuration = 500, scrollRef, onTap, onDoubleTap, onPinchStart, onPinchEnd, onLongPress, onUpdate, onGestureEnd, }) => { const containerRef = useAnimatedRef(); const translate = useVector(0, 0); const scale = useSharedValue(1); const position = useVector(0, 0); const origin = useVector(0, 0); const initialFocal = useVector(0, 0); const currentFocal = useVector(0, 0); const containerSize = useSizeVector( resizeConfig?.size.width ?? 0, resizeConfig?.size.height ?? 0 ); const measureContainer = () => { 'worklet'; const measuremet = measure(containerRef); if (measuremet !== null) { containerSize.width.value = measuremet.width; containerSize.height.value = measuremet.height; position.x.value = measuremet.pageX; position.y.value = measuremet.pageY; } }; const childrenSize = useDerivedValue(() => { return resizeToAspectRatio({ resizeConfig, width: containerSize.width.value, height: containerSize.height.value, scale: scale.value, }); }, [resizeConfig, containerSize, scale]); useDerivedValue(() => { const state: SnapbackZoomState = { size: { width: containerSize.width.value, height: containerSize.height.value, }, position: { x: position.x.value, y: position.y.value, }, translateX: translate.x.value - childrenSize.value.deltaX, translateY: translate.y.value - childrenSize.value.deltaY, scale: scale.value, }; if (resizeConfig !== undefined) { state.resize = { width: childrenSize.value.width, height: childrenSize.value.height, }; } onUpdate?.(state); }, [containerSize, position, childrenSize, resizeConfig, translate, scale]); const pinch = Gesture.Pinch() .withTestId('pinch') .hitSlop(hitSlop) .enabled(gesturesEnabled) .onTouchesMove((e) => { if (e.numberOfTouches !== 2) return; const one = e.allTouches[0]!; const two = e.allTouches[1]!; currentFocal.x.value = (one.absoluteX + two.absoluteX) / 2; currentFocal.y.value = (one.absoluteY + two.absoluteY) / 2; }) .onStart((e) => { measureContainer(); onPinchStart && runOnJS(onPinchStart)(e); initialFocal.x.value = currentFocal.x.value; initialFocal.y.value = currentFocal.y.value; origin.x.value = e.focalX - containerSize.width.value / 2; origin.y.value = e.focalY - containerSize.height.value / 2; }) .onUpdate((e) => { measureContainer(); const deltaX = currentFocal.x.value - initialFocal.x.value; const deltaY = currentFocal.y.value - initialFocal.y.value; const toX = -1 * (origin.x.value * e.scale - origin.x.value) + deltaX; const toY = -1 * (origin.y.value * e.scale - origin.y.value) + deltaY; translate.x.value = toX; translate.y.value = toY; scale.value = e.scale; }) .onEnd((e) => { onPinchEnd && runOnJS(onPinchEnd)(e); translate.x.value = withTiming(0, timingConfig); translate.y.value = withTiming(0, timingConfig); scale.value = withTiming(1, timingConfig, (_) => { onGestureEnd && runOnJS(onGestureEnd)(); }); }); if (scrollRef !== undefined) { pinch.blocksExternalGesture(scrollRef); } const tap = Gesture.Tap() .withTestId('tap') .enabled(gesturesEnabled) .maxDuration(250) .numberOfTaps(1) .runOnJS(true) .onEnd((e) => onTap?.(e)); const doubleTap = Gesture.Tap() .withTestId('doubleTap') .enabled(gesturesEnabled) .numberOfTaps(2) .maxDuration(250) .runOnJS(true) .onEnd((e) => onDoubleTap?.(e)); const longPress = Gesture.LongPress() .withTestId('longPress') .enabled(gesturesEnabled) .minDuration(longPressDuration) .runOnJS(true) .onStart((e) => onLongPress?.(e)); const containerStyle = useAnimatedStyle( () => ({ width: resizeConfig?.size.width, height: resizeConfig?.size.height, justifyContent: 'center', alignItems: 'center', }), [resizeConfig] ); const childStyle = useAnimatedStyle(() => { const { width, height, deltaX, deltaY } = childrenSize.value; return { width: width === 0 ? undefined : width, height: height === 0 ? undefined : height, transform: [ { translateX: translate.x.value - deltaX }, { translateY: translate.y.value - deltaY }, { scale: scale.value }, ], }; }, [childrenSize, translate, scale]); const composedTapGesture = Gesture.Exclusive(doubleTap, tap, longPress); return ( {children} ); }; export default withSnapbackValidation(SnapbackZoom);