/** * ZoomableImage — Pinch-to-Zoom + Pan fuers Vollbild-Modal. * * Reine RN-Implementation, ohne react-native-gesture-handler. * * - 2 Finger: Pinch (Zoom 1x..5x) + simultaner Pan via Focal-Punkt * - 1 Finger: Pan wenn schon gezoomt (>1.02x) * - Doppel-Tap (<300ms zw. zwei Single-Taps): Toggle 1x ↔ 2.5x * * Implementierungs-Hinweise zur alten Version (warum's nicht ging): * - `gestureState.numberActiveTouches` ist nicht zuverlaessig direkt * nach onPanResponderGrant. Wir lesen Finger-Anzahl jetzt * ausschliesslich aus `e.nativeEvent.touches.length`. * - Beim Wechsel von 2 → 1 Fingern bleib die Pinch-Referenz haengen. * Jetzt: bei jedem Finger-Wechsel re-snapshotten wir die Geste. * - Animated.Image bekommt jetzt pointerEvents="none" damit der View * GARANTIERT die Touches abbekommt. * - useNativeDriver ist bewusst AUS — sonst koennen wir setValue() * nicht synchron mit dem Pan-Responder zusammen nutzen. */ import React, { useMemo, useRef } from 'react'; import { Animated, PanResponder, GestureResponderEvent, ImageStyle, StyleProp, StyleSheet, View, } from 'react-native'; interface Props { uri: string; containerWidth: number; containerHeight: number; style?: StyleProp; } const MIN_SCALE = 1; const MAX_SCALE = 5; const DOUBLE_TAP_MS = 300; const DOUBLE_TAP_DIST = 30; // Bewegung max. damit ein Tap als Tap gilt const PAN_SLOP_AT_SCALE_1 = 4; // Mikro-Movement nicht als Pan werten const ZoomableImage: React.FC = ({ uri, containerWidth, containerHeight, style }) => { // Animated-Werte fuer die Render-Transformation const scale = useRef(new Animated.Value(1)).current; const tx = useRef(new Animated.Value(0)).current; const ty = useRef(new Animated.Value(0)).current; // Logische Zustaende — wir lesen Animated.Value nicht zurueck (waere async) const view = useRef({ scale: 1, x: 0, y: 0 }).current; // Geste-Snapshot: was war zu Beginn dieser Geste-Phase const gesture = useRef({ fingers: 0, // aktuelle Finger-Anzahl startScale: 1, startX: 0, startY: 0, startDist: 0, // Pinch-Referenz-Distanz startFocalX: 0, startFocalY: 0, movedSinceTouch: 0, // fuer Tap-Erkennung touchStartedAt: 0, touchStartX: 0, touchStartY: 0, }).current; // Doppel-Tap const lastTap = useRef({ at: 0, x: 0, y: 0 }); const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); const applyClamped = (s: number, x: number, y: number) => { const ns = clamp(s, MIN_SCALE, MAX_SCALE); // Translation auf das verfuegbare Volumen begrenzen const maxX = Math.max(0, (containerWidth * ns - containerWidth) / 2); const maxY = Math.max(0, (containerHeight * ns - containerHeight) / 2); const nx = clamp(x, -maxX, maxX); const ny = clamp(y, -maxY, maxY); view.scale = ns; view.x = nx; view.y = ny; scale.setValue(ns); tx.setValue(nx); ty.setValue(ny); }; const distance = (touches: any[]) => Math.hypot(touches[0].pageX - touches[1].pageX, touches[0].pageY - touches[1].pageY); const focal = (touches: any[]) => ({ x: (touches[0].pageX + touches[1].pageX) / 2, y: (touches[0].pageY + touches[1].pageY) / 2, }); // Snapshot vor jedem Phasenwechsel (1↔2 Finger) — verhindert Spruenge const snapshot = (touches: any[]) => { gesture.startScale = view.scale; gesture.startX = view.x; gesture.startY = view.y; if (touches.length >= 2) { gesture.startDist = distance(touches); const f = focal(touches); gesture.startFocalX = f.x; gesture.startFocalY = f.y; } else if (touches.length === 1) { gesture.startDist = 0; gesture.startFocalX = touches[0].pageX; gesture.startFocalY = touches[0].pageY; } }; const responder = useMemo( () => PanResponder.create({ onStartShouldSetPanResponder: () => true, onStartShouldSetPanResponderCapture: () => true, onMoveShouldSetPanResponder: () => true, onMoveShouldSetPanResponderCapture: () => true, onPanResponderGrant: (e: GestureResponderEvent) => { const touches = e.nativeEvent.touches as any[]; gesture.fingers = touches.length; gesture.movedSinceTouch = 0; gesture.touchStartedAt = Date.now(); gesture.touchStartX = touches[0]?.pageX ?? 0; gesture.touchStartY = touches[0]?.pageY ?? 0; snapshot(touches); }, onPanResponderMove: (e: GestureResponderEvent, _gs) => { const touches = e.nativeEvent.touches as any[]; // Phasenwechsel? → Re-Snapshot, damit nicht gesprungen wird if (touches.length !== gesture.fingers) { gesture.fingers = touches.length; snapshot(touches); return; } gesture.movedSinceTouch += 1; if (touches.length >= 2) { // Pinch + Pan via Focal const d = distance(touches); if (gesture.startDist === 0) { // Sicherheitsnetz falls Snapshot gemissed wurde snapshot(touches); return; } const factor = d / gesture.startDist; const f = focal(touches); const newScale = clamp(gesture.startScale * factor, MIN_SCALE, MAX_SCALE); // Focal-basierter Pan: zoomt um den Mittelpunkt der zwei Finger const newX = gesture.startX + (f.x - gesture.startFocalX); const newY = gesture.startY + (f.y - gesture.startFocalY); applyClamped(newScale, newX, newY); } else if (touches.length === 1 && view.scale > 1.02) { const dx = touches[0].pageX - gesture.startFocalX; const dy = touches[0].pageY - gesture.startFocalY; if (Math.abs(dx) < PAN_SLOP_AT_SCALE_1 && Math.abs(dy) < PAN_SLOP_AT_SCALE_1) return; applyClamped(view.scale, gesture.startX + dx, gesture.startY + dy); } }, onPanResponderRelease: (e: GestureResponderEvent) => { const elapsed = Date.now() - gesture.touchStartedAt; const dx = (e.nativeEvent.changedTouches?.[0]?.pageX ?? gesture.touchStartX) - gesture.touchStartX; const dy = (e.nativeEvent.changedTouches?.[0]?.pageY ?? gesture.touchStartY) - gesture.touchStartY; const wasTap = elapsed < 280 && Math.abs(dx) < DOUBLE_TAP_DIST && Math.abs(dy) < DOUBLE_TAP_DIST; if (wasTap) { const now = Date.now(); if (now - lastTap.current.at < DOUBLE_TAP_MS) { // Doppel-Tap → Zoom-Toggle if (view.scale > 1.1) { applyClamped(1, 0, 0); } else { applyClamped(2.5, 0, 0); } lastTap.current = { at: 0, x: 0, y: 0 }; } else { lastTap.current = { at: now, x: gesture.touchStartX, y: gesture.touchStartY }; } } gesture.fingers = 0; gesture.startDist = 0; }, onPanResponderTerminate: () => { gesture.fingers = 0; gesture.startDist = 0; }, }), [], ); return ( ); }; export default ZoomableImage;