# Image Detail Navigation & Gallery Sync Dokumentation der Implementation der Bild-Detail-Ansicht mit horizontaler Swipe-Navigation und automatischer Positions-Synchronisation mit der Galerie. ## Überblick Die Bild-Detail-Ansicht bietet eine vollständige, iOS Photos-ähnliche Erfahrung: - Horizontales Swipen zwischen allen Bildern - Pinch-to-Zoom Funktionalität - Pull-to-Close (Runterswipen zum Schließen) - Automatische Synchronisation mit der Galerie-Position - Fullscreen-Darstellung mit versteckbaren UI-Elementen ## Hauptkomponenten ### 1. Horizontale Bild-Navigation **Implementierung:** `app/image/[id].tsx` Verwendet `react-native-pager-view` für natives Swipe-Verhalten: ```typescript import PagerView from 'react-native-pager-view'; {allImages.map((item) => ( router.back()} /> ))} ``` **Warum PagerView statt FlatList?** - Native Swipe-Performance auf iOS/Android - Bessere Kompatibilität mit Pinch/Pan Gestures - Keine Konflikte mit Zoom-Gesten ### 2. Zoomable Image Komponente **Komponente:** `ZoomableImage` in `app/image/[id].tsx` Eigene Implementierung mit `react-native-gesture-handler` und `react-native-reanimated`: ```typescript function ZoomableImage({ item, uiVisible, setUiVisible, onClose }) { const scale = useSharedValue(1); const translateY = useSharedValue(0); const dismissProgress = useSharedValue(0); // Pinch Gesture für Zoom const pinchGesture = Gesture.Pinch() .onUpdate((event) => { scale.value = savedScale.value * event.scale; }) .onEnd(() => { if (scale.value < 1) { scale.value = withSpring(1); // Reset position } else { savedScale.value = scale.value; } }); // Vertical Pan für Pull-to-Close const verticalPanGesture = Gesture.Pan() .activeOffsetY([-10, 10]) .failOffsetX([-10, 10]) // Wichtig: Verhindert Konflikt mit horizontalem Swipe .onUpdate((event) => { if (scale.value === 1) { translateY.value = event.translationY; dismissProgress.value = Math.min(Math.abs(event.translationY) / 200, 1); } }) .onEnd((event) => { if (scale.value === 1 && Math.abs(event.translationY) > 100) { runOnJS(onClose)(); } else { translateY.value = withSpring(0); dismissProgress.value = withSpring(0); } }); // Double-Tap für schnellen Zoom const doubleTap = Gesture.Tap() .numberOfTaps(2) .onEnd(() => { if (scale.value > 1) { scale.value = withSpring(1); // Reset } else { scale.value = withSpring(2); savedScale.value = 2; } }); // Single-Tap für UI Toggle const singleTap = Gesture.Tap() .numberOfTaps(1) .onEnd(() => { runOnJS(setUiVisible)(!uiVisible); }); // Kombinierte Gesten const composed = Gesture.Race( doubleTap, Gesture.Simultaneous(verticalPanGesture, pinchGesture, singleTap) ); } ``` **Wichtige Gesture-Konfiguration:** - `activeOffsetY([-10, 10])` - Aktiviert vertikale Geste erst ab 10px - `failOffsetX([-10, 10])` - Deaktiviert bei horizontaler Bewegung (wichtig für PagerView!) - `Gesture.Race()` - Double-Tap hat Priorität vor Single-Tap - `Gesture.Simultaneous()` - Mehrere Gesten gleichzeitig möglich ### 3. Pull-to-Close Effekt **Visual Feedback während des Draggings:** ```typescript const containerStyle = useAnimatedStyle(() => ({ opacity: 1 - dismissProgress.value * 0.5, // Fade out bis 50% })); const animatedStyle = useAnimatedStyle(() => ({ transform: [ { translateY: translateY.value }, { scale: scale.value }, ], })); ``` **Schwarzer Hintergrund:** Alle Container haben `backgroundColor: '#000'` für konsistentes Erscheinungsbild beim Pull-to-Close. ### 4. Gallery Position Sync **Problem:** Wenn du in der Detail-Ansicht von Bild 1 zu Bild 5 swipest und dann schließt, landest du wieder bei Bild 1 in der Galerie. **Lösung:** Zustand Store für geteilten State zwischen Detail- und Galerie-Ansicht. #### ViewStore erweitern **Datei:** `store/viewStore.ts` ```typescript type ViewStore = { galleryViewMode: ViewMode; exploreViewMode: ViewMode; lastViewedImageId: string | null; // NEU setGalleryViewMode: (mode: ViewMode) => void; setExploreViewMode: (mode: ViewMode) => void; setLastViewedImageId: (id: string | null) => void; // NEU }; export const useViewStore = create()( persist( (set) => ({ galleryViewMode: 'grid3', exploreViewMode: 'grid3', lastViewedImageId: null, // NEU setGalleryViewMode: (mode) => set({ galleryViewMode: mode }), setExploreViewMode: (mode) => set({ exploreViewMode: mode }), setLastViewedImageId: (id) => set({ lastViewedImageId: id }), // NEU }), { name: 'view-storage', storage: createJSONStorage(() => AsyncStorage), } ) ); ``` #### Detail-Ansicht: Position speichern **Datei:** `app/image/[id].tsx` ```typescript const { setLastViewedImageId } = useViewStore(); // Update current image details when index changes useEffect(() => { if (allImages.length > 0 && allImages[currentIndex]) { const currentImage = allImages[currentIndex]; setImage(currentImage); // ... andere Updates // Save last viewed image ID to store setLastViewedImageId(currentImage.id); } }, [currentIndex, allImages]); ``` Jedes Mal wenn der User zu einem anderen Bild swipet, wird die ID im Store gespeichert. #### Galerie-Ansicht: Zur Position scrollen **Datei:** `app/(tabs)/index/index.tsx` ```typescript import { useFocusEffect } from 'expo-router'; const { lastViewedImageId, setLastViewedImageId } = useViewStore(); const flatListRef = useRef(null); // Scroll to last viewed image when screen comes into focus useFocusEffect( useCallback(() => { if (lastViewedImageId && filteredImages.length > 0) { const index = filteredImages.findIndex(img => img.id === lastViewedImageId); if (index !== -1 && flatListRef.current) { setTimeout(() => { try { flatListRef.current?.scrollToIndex({ index, animated: false, // Kein Scrollen, sofort erscheinen viewPosition: 0.5, // Item zentrieren }); } catch (error) { console.log('ScrollToIndex failed'); } }, 100); // Clear after scrolling setTimeout(() => { setLastViewedImageId(null); }, 600); } } }, [lastViewedImageId, filteredImages]) ); ``` **FlatList mit Fallback Handler:** ```typescript { // Fallback wenn Item noch nicht gerendert ist const wait = new Promise(resolve => setTimeout(resolve, 500)); wait.then(() => { flatListRef.current?.scrollToIndex({ index: info.index, animated: false }); }); }} // ... andere Props /> ``` **Wichtige Details:** - `useFocusEffect` wird aufgerufen wenn Screen in den Fokus kommt - `animated: false` verhindert sichtbares Scrollen - 100ms Delay gibt FlatList Zeit zum Rendern - `onScrollToIndexFailed` als Fallback wenn Item noch nicht geladen - ID wird nach 600ms gelöscht, damit nicht beim nächsten Öffnen wieder dorthin gescrollt wird ## Navigation-Buttons **Top Bar Buttons für manuelle Navigation:** ```typescript {/* Previous Button */} { if (currentIndex > 0) { pagerRef.current?.setPage(currentIndex - 1); } }} disabled={currentIndex === 0} style={{ opacity: currentIndex === 0 ? 0.3 : 1, }} > {/* Next Button */} { if (currentIndex < allImages.length - 1) { pagerRef.current?.setPage(currentIndex + 1); } }} disabled={currentIndex === allImages.length - 1} style={{ opacity: currentIndex === allImages.length - 1 ? 0.3 : 1, }} > ``` **Page Indicator:** ```typescript {allImages.length > 1 && ( {currentIndex + 1} / {allImages.length} )} ``` ## Technische Herausforderungen & Lösungen ### 1. Gesture-Konflikte **Problem:** Horizontal Swipe (PagerView) vs. Vertical Pan (Pull-to-Close) vs. Pinch (Zoom) **Lösung:** - `failOffsetX` verhindert vertikales Pan bei horizontalem Swipe - `activeOffsetY` aktiviert Pull-to-Close erst ab 10px vertikaler Bewegung - `Gesture.Race` für Tap-Prioritäten - `Gesture.Simultaneous` für gleichzeitige Pinch + Tap ### 2. FlatList Grid-Layout Scrolling **Problem:** `initialScrollIndex` und `getItemLayout` funktionieren nicht zuverlässig mit `numColumns`. **Lösung:** - `useFocusEffect` statt `initialScrollIndex` - `animated: false` für sofortiges Erscheinen - `onScrollToIndexFailed` als Fallback - Delay für FlatList Rendering-Zeit ### 3. Schwarzer Hintergrund beim Pull-to-Close **Problem:** Weißer Hintergrund erscheint beim Runterswipen. **Lösung:** ```typescript {/* Image */} ``` Alle Container-Ebenen müssen explizit `backgroundColor: '#000'` haben. ## Performance-Optimierungen ### 1. Lazy Loading in PagerView PagerView rendert nur die aktuelle Page + 1-2 benachbarte Pages: ```typescript // Automatisch durch PagerView optimiert // windowSize wird intern gemanagt ``` ### 2. Image Prefetching Bereits implementiert in der Galerie (siehe `IMAGE_PERFORMANCE_OPTIMIZATION.md`): - Thumbnails werden vorgeladen - Progressive Loading mit BlurHash ### 3. Memo für Render-Optimierung ```typescript const renderImageItem = ({ item }: { item: ImageDetails }) => ( router.back()} /> ); ``` Jede Page wird nur neu gerendert wenn `item` sich ändert. ## Benutzer-Flow 1. **Galerie öffnen** → Bilder in Grid-Ansicht 2. **Bild antippen** → Detail-Ansicht öffnet bei diesem Bild 3. **Horizontal swipen** → Zwischen allen Bildern navigieren 4. **Pinch** → Zoomen 5. **Double-Tap** → 2x Zoom Toggle 6. **Single-Tap** → UI ein/ausblenden 7. **Runterswipen** → Detail-Ansicht schließen 8. **Galerie erscheint** → Direkt an der Position des zuletzt angesehenen Bildes ## Abhängigkeiten ```json { "react-native-pager-view": "6.9.1", "react-native-gesture-handler": "~2.28.0", "react-native-reanimated": "~4.1.1", "zustand": "^4.5.1", "@react-native-async-storage/async-storage": "2.2.0" } ``` ## Best Practices 1. **Immer `backgroundColor: '#000'`** auf allen Container-Ebenen für Fullscreen-Ansichten 2. **`animated: false`** bei `scrollToIndex` für sofortiges Erscheinen 3. **`failOffsetX/Y`** für Gesture-Konflikt-Vermeidung 4. **`useFocusEffect`** statt `useEffect` für Screen-Focus-Logik 5. **`onScrollToIndexFailed`** immer implementieren bei dynamischen Listen 6. **Zustand Store** für Screen-übergreifenden State 7. **100-300ms Delays** für FlatList/PagerView Rendering ## Zukünftige Verbesserungen - [ ] Swipe-Velocity für schnelleres Blättern - [ ] Shared Element Transition beim Öffnen/Schließen - [ ] Video-Support mit gleicher Navigation - [ ] Batch-Aktionen für mehrere Bilder - [ ] Zoom-Level persistieren pro Bild ## Verwandte Dokumentation - [IMAGE_PERFORMANCE_OPTIMIZATION.md](./IMAGE_PERFORMANCE_OPTIMIZATION.md) - Bild-Optimierungen - [CLAUDE.md](../../CLAUDE.md) - Projekt-Übersicht