12 KiB
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:
import PagerView from 'react-native-pager-view';
<PagerView
ref={pagerRef}
style={{ flex: 1, backgroundColor: '#000' }}
initialPage={currentIndex}
onPageSelected={onPageSelected}
>
{allImages.map((item) => (
<View key={item.id} style={{ flex: 1, backgroundColor: '#000' }}>
<ZoomableImage
item={item}
uiVisible={uiVisible}
setUiVisible={setUiVisible}
onClose={() => router.back()}
/>
</View>
))}
</PagerView>
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:
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 10pxfailOffsetX([-10, 10])- Deaktiviert bei horizontaler Bewegung (wichtig für PagerView!)Gesture.Race()- Double-Tap hat Priorität vor Single-TapGesture.Simultaneous()- Mehrere Gesten gleichzeitig möglich
3. Pull-to-Close Effekt
Visual Feedback während des Draggings:
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
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<ViewStore>()(
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
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
import { useFocusEffect } from 'expo-router';
const { lastViewedImageId, setLastViewedImageId } = useViewStore();
const flatListRef = useRef<FlatList>(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:
<FlatList
ref={flatListRef}
data={filteredImages}
onScrollToIndexFailed={(info) => {
// 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:
useFocusEffectwird aufgerufen wenn Screen in den Fokus kommtanimated: falseverhindert sichtbares Scrollen- 100ms Delay gibt FlatList Zeit zum Rendern
onScrollToIndexFailedals 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:
{/* Previous Button */}
<Pressable
onPress={() => {
if (currentIndex > 0) {
pagerRef.current?.setPage(currentIndex - 1);
}
}}
disabled={currentIndex === 0}
style={{
opacity: currentIndex === 0 ? 0.3 : 1,
}}
>
<Ionicons name="chevron-back" size={24} color="#fff" />
</Pressable>
{/* Next Button */}
<Pressable
onPress={() => {
if (currentIndex < allImages.length - 1) {
pagerRef.current?.setPage(currentIndex + 1);
}
}}
disabled={currentIndex === allImages.length - 1}
style={{
opacity: currentIndex === allImages.length - 1 ? 0.3 : 1,
}}
>
<Ionicons name="chevron-forward" size={24} color="#fff" />
</Pressable>
Page Indicator:
{allImages.length > 1 && (
<View style={{
backgroundColor: 'rgba(0,0,0,0.5)',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 12,
}}>
<Text variant="bodySmall" style={{ color: '#fff' }}>
{currentIndex + 1} / {allImages.length}
</Text>
</View>
)}
Technische Herausforderungen & Lösungen
1. Gesture-Konflikte
Problem: Horizontal Swipe (PagerView) vs. Vertical Pan (Pull-to-Close) vs. Pinch (Zoom)
Lösung:
failOffsetXverhindert vertikales Pan bei horizontalem SwipeactiveOffsetYaktiviert Pull-to-Close erst ab 10px vertikaler BewegungGesture.Racefür Tap-PrioritätenGesture.Simultaneousfür gleichzeitige Pinch + Tap
2. FlatList Grid-Layout Scrolling
Problem: initialScrollIndex und getItemLayout funktionieren nicht zuverlässig mit numColumns.
Lösung:
useFocusEffectstattinitialScrollIndexanimated: falsefür sofortiges ErscheinenonScrollToIndexFailedals Fallback- Delay für FlatList Rendering-Zeit
3. Schwarzer Hintergrund beim Pull-to-Close
Problem: Weißer Hintergrund erscheint beim Runterswipen.
Lösung:
<GestureHandlerRootView style={{ flex: 1, backgroundColor: '#000' }}>
<PagerView style={{ flex: 1, backgroundColor: '#000' }}>
<View style={{ flex: 1, backgroundColor: '#000' }}>
<Animated.View style={{ backgroundColor: '#000' }}>
{/* Image */}
</Animated.View>
</View>
</PagerView>
</GestureHandlerRootView>
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:
// 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
const renderImageItem = ({ item }: { item: ImageDetails }) => (
<ZoomableImage
item={item}
uiVisible={uiVisible}
setUiVisible={setUiVisible}
onClose={() => router.back()}
/>
);
Jede Page wird nur neu gerendert wenn item sich ändert.
Benutzer-Flow
- Galerie öffnen → Bilder in Grid-Ansicht
- Bild antippen → Detail-Ansicht öffnet bei diesem Bild
- Horizontal swipen → Zwischen allen Bildern navigieren
- Pinch → Zoomen
- Double-Tap → 2x Zoom Toggle
- Single-Tap → UI ein/ausblenden
- Runterswipen → Detail-Ansicht schließen
- Galerie erscheint → Direkt an der Position des zuletzt angesehenen Bildes
Abhängigkeiten
{
"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
- Immer
backgroundColor: '#000'auf allen Container-Ebenen für Fullscreen-Ansichten animated: falsebeiscrollToIndexfür sofortiges ErscheinenfailOffsetX/Yfür Gesture-Konflikt-VermeidunguseFocusEffectstattuseEffectfür Screen-Focus-LogikonScrollToIndexFailedimmer implementieren bei dynamischen Listen- Zustand Store für Screen-übergreifenden State
- 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 - Bild-Optimierungen
- CLAUDE.md - Projekt-Übersicht