feat(app): Pinch-Zoom + Pan im Vollbild-Modal

Neue ZoomableImage-Komponente — reine RN-Implementation mit
PanResponder + Animated, ohne extra Dependency.

- 2-Finger-Pinch: Zoom 1x..5x, Focal-Point folgt der Geste
- 1-Finger-Pan: nur aktiv wenn gezoomt, mit Bounds-Clamping
- Doppel-Tap: Toggle 1x ↔ 2.5x

Vollbild-Modal ersetzt das simple <Image> durch ZoomableImage fuer
JPEG/PNG/etc. SVGs bleiben non-zoomable (SvgUri-Limitation), Tap
schliesst sie. Plus dedicated ✕-Close-Button oben rechts da Tap-to-
Close mit PanResponder kollidiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 19:15:58 +02:00
parent 8b4f75bf91
commit e438bb11ff
2 changed files with 162 additions and 11 deletions
+142
View File
@@ -0,0 +1,142 @@
/**
* ZoomableImage — Pinch-to-Zoom + Pan fuer das Vollbild-Modal.
*
* Reine React-Native-Implementation ohne externe Lib:
* - 1 Finger: Pan wenn schon gezoomt
* - 2 Finger: Pinch fuer Zoom + Pan
* - Doppel-Tap: Toggle 1x ↔ 2.5x Zoom
*
* Scale wird auf [1, 5] gecapped, Translation auf das verfuegbare
* Image-Volumen (kein Out-of-bounds-Pan).
*/
import React, { useRef } from 'react';
import { Animated, PanResponder, View, StyleSheet, ImageStyle, StyleProp } from 'react-native';
interface Props {
uri: string;
containerWidth: number;
containerHeight: number;
style?: StyleProp<ImageStyle>;
}
const ZoomableImage: React.FC<Props> = ({ uri, containerWidth, containerHeight, style }) => {
const scale = useRef(new Animated.Value(1)).current;
const translateX = useRef(new Animated.Value(0)).current;
const translateY = useRef(new Animated.Value(0)).current;
// Aktuelle Werte (Animated.Value lesen ist async, wir tracken parallel)
const current = useRef({ scale: 1, x: 0, y: 0 }).current;
// State beim Geste-Start (touchStart-Snapshot)
const start = useRef({ scale: 1, x: 0, y: 0, distance: 0, focalX: 0, focalY: 0 }).current;
// Doppel-Tap-Erkennung
const lastTapAt = useRef(0);
const distance = (touches: any[]) => {
const [a, b] = touches;
return Math.hypot(a.pageX - b.pageX, a.pageY - b.pageY);
};
const focal = (touches: any[]) => {
const [a, b] = touches;
return { x: (a.pageX + b.pageX) / 2, y: (a.pageY + b.pageY) / 2 };
};
const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v));
const applyAndClamp = (newScale: number, newX: number, newY: number) => {
const s = clamp(newScale, 1, 5);
// Maximal-Translation: (imgSize * scale - imgSize) / 2
const maxX = Math.max(0, (containerWidth * s - containerWidth) / 2);
const maxY = Math.max(0, (containerHeight * s - containerHeight) / 2);
const x = clamp(newX, -maxX, maxX);
const y = clamp(newY, -maxY, maxY);
current.scale = s;
current.x = x;
current.y = y;
scale.setValue(s);
translateX.setValue(x);
translateY.setValue(y);
};
const responder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderGrant: (_e, gestureState) => {
const touches = gestureState as any;
const t = touches.numberActiveTouches || 1;
// Doppel-Tap-Erkennung (nur bei 1 Finger)
if (t === 1) {
const now = Date.now();
if (now - lastTapAt.current < 280) {
// Doppel-Tap → Zoom-Toggle
if (current.scale > 1.1) {
applyAndClamp(1, 0, 0);
} else {
applyAndClamp(2.5, 0, 0);
}
lastTapAt.current = 0;
return;
}
lastTapAt.current = now;
}
start.scale = current.scale;
start.x = current.x;
start.y = current.y;
},
onPanResponderMove: (e, gestureState) => {
const touches = e.nativeEvent.touches;
if (touches.length >= 2) {
// Pinch + Pan
if (start.distance === 0) {
// Initialisiere die Pinch-Referenz beim Uebergang 1→2 Finger
start.distance = distance(touches);
const f = focal(touches);
start.focalX = f.x;
start.focalY = f.y;
start.scale = current.scale;
start.x = current.x;
start.y = current.y;
return;
}
const newDistance = distance(touches);
const newFocal = focal(touches);
const scaleFactor = newDistance / start.distance;
const newScale = clamp(start.scale * scaleFactor, 1, 5);
// Pan-Anteil aus Focal-Bewegung
const newX = start.x + (newFocal.x - start.focalX);
const newY = start.y + (newFocal.y - start.focalY);
applyAndClamp(newScale, newX, newY);
} else if (touches.length === 1 && current.scale > 1.05) {
// Single-Finger-Pan nur wenn gezoomt
start.distance = 0; // Reset Pinch-Tracking
applyAndClamp(current.scale, start.x + gestureState.dx, start.y + gestureState.dy);
}
},
onPanResponderRelease: () => { start.distance = 0; },
onPanResponderTerminate: () => { start.distance = 0; },
}),
).current;
return (
<View style={StyleSheet.absoluteFill} {...responder.panHandlers}>
<Animated.Image
source={{ uri }}
style={[
style,
{
transform: [
{ translateX },
{ translateY },
{ scale },
],
},
]}
resizeMode="contain"
/>
</View>
);
};
export default ZoomableImage;