import React, { useRef } from 'react'; import { Animated, PanResponder } from 'react-native'; // Pinch / double-tap zoom for one preview photo inside the paging strip. // Core PanResponder + Animated only (no react-native-gesture-handler dep). // While zoomed in, responder termination is refused so the parent ScrollView // cannot steal the gesture — the photo pans instead of paging; swiping to the // next photo resumes after zooming back out (double tap or pinch below 1.15). // Pinch zooms from any scale: a two-finger gesture never pages the strip, so // pinching out of 100% cannot be mistaken for a swipe. // // ponytail: pinch zooms around the image center, not the finger midpoint; // focal zoom needs per-touch location math against the image's screen rect. // MAX_SCALE is a hard cap — add a dedicated slider/stepper when 4x is not // enough. interface ZoomableImageProps { uri: string; width: number; } const MAX_SCALE = 4; const TAP_SCALE = 2.5; const TAP_MS = 300; export default function ZoomableImage({ uri, width }: ZoomableImageProps) { const scaleAn = useRef(new Animated.Value(1)).current; const txAn = useRef(new Animated.Value(0)).current; const tyAn = useRef(new Animated.Value(0)).current; const st = useRef({ scale: 1, tx: 0, ty: 0, w: width, h: 0, pinchStart: null as { scale: number; dist: number } | null, lastDx: 0, lastDy: 0, moved: false, grantAt: 0, lastTap: 0, }).current; const apply = () => { scaleAn.setValue(st.scale); txAn.setValue(st.tx); tyAn.setValue(st.ty); }; const clampTx = () => { // Keep the scaled image covering the center; pan bounds grow with zoom. const k = (st.scale - 1) / 2; const mx = st.w * k; st.tx = Math.max(-mx, Math.min(mx, st.tx)); if (st.h > 0) { const my = st.h * k; st.ty = Math.max(-my, Math.min(my, st.ty)); } }; const settle = (scale: number, tx: number, ty: number) => { st.scale = scale; st.tx = tx; st.ty = ty; Animated.parallel([ Animated.spring(scaleAn, { toValue: scale, friction: 8, tension: 80, useNativeDriver: false }), Animated.spring(txAn, { toValue: tx, friction: 8, tension: 80, useNativeDriver: false }), Animated.spring(tyAn, { toValue: ty, friction: 8, tension: 80, useNativeDriver: false }), ]).start(); }; const finish = () => { const wasTap = !st.moved && Date.now() - st.grantAt < 400 && st.pinchStart === null; st.pinchStart = null; st.lastDx = 0; st.lastDy = 0; st.moved = false; if (wasTap) { if (Date.now() - st.lastTap < TAP_MS) { st.lastTap = 0; if (st.scale > 1.05) settle(1, 0, 0); else settle(TAP_SCALE, 0, 0); } else { st.lastTap = Date.now(); } return; } if (st.scale < 1.15) { if (st.scale !== 1 || st.tx !== 0 || st.ty !== 0) settle(1, 0, 0); } else { clampTx(); apply(); } }; const pan = useRef( PanResponder.create({ onStartShouldSetPanResponder: () => true, onMoveShouldSetPanResponder: () => true, // Page the strip while zoom is off; once zoomed (or mid-pinch) the // image keeps the gesture for panning/zooming itself. onPanResponderTerminationRequest: () => st.scale <= 1.001 && st.pinchStart === null, onPanResponderGrant: () => { st.grantAt = Date.now(); st.moved = false; st.lastDx = 0; st.lastDy = 0; }, onPanResponderMove: (e, g) => { const touches = e.nativeEvent.touches; if (touches.length >= 2) { const a = touches[0]; const b = touches[1]; const dist = Math.hypot(a.pageX - b.pageX, a.pageY - b.pageY); if (!st.pinchStart) st.pinchStart = { scale: st.scale, dist: dist || 1 }; const next = Math.max( 1, Math.min(MAX_SCALE, st.pinchStart.scale * (dist / st.pinchStart.dist)) ); st.scale = next; if (next <= 1.001) { st.tx = 0; st.ty = 0; } clampTx(); apply(); st.moved = true; st.lastDx = g.dx; st.lastDy = g.dy; } else { st.pinchStart = null; if (Math.abs(g.dx) > 8 || Math.abs(g.dy) > 8) st.moved = true; if (st.scale > 1.001) { st.tx += g.dx - st.lastDx; st.ty += g.dy - st.lastDy; clampTx(); apply(); } st.lastDx = g.dx; st.lastDy = g.dy; } }, onPanResponderRelease: finish, onPanResponderTerminate: finish, }) ).current; return ( { st.w = e.nativeEvent.layout.width; st.h = e.nativeEvent.layout.height; }} style={[ { width, height: '100%' }, { transform: [{ translateX: txAn }, { translateY: tyAn }, { scale: scaleAn }] }, ]} {...pan.panHandlers} /> ); }