zoomable image

This commit is contained in:
m
2026-07-16 09:37:59 +02:00
parent 04e74316f9
commit 8efa1e18c2
2 changed files with 79 additions and 31 deletions
+57 -19
View File
@@ -1,4 +1,4 @@
import React, { useRef, useState } from 'react'; import React, { useRef, useState, useCallback } from 'react';
import { import {
View, View,
Text, Text,
@@ -23,13 +23,15 @@ const SCREEN_WIDTH = Dimensions.get('window').width;
type SelectedImage = { uri: string; width: number; height: number }; type SelectedImage = { uri: string; width: number; height: number };
type ModalState = { images: SelectedImage[]; index: number } | null;
type RootStackParamList = { type RootStackParamList = {
FileDetail: { fileIds: string[]; initialIndex: number }; FileDetail: { fileIds: string[]; initialIndex: number };
}; };
type FileDetailRouteProp = RouteProp<RootStackParamList, 'FileDetail'>; type FileDetailRouteProp = RouteProp<RootStackParamList, 'FileDetail'>;
function DetailItem({ fileId, onSelectImage }: { fileId: string; onSelectImage?: (img: SelectedImage) => void }) { function DetailItem({ fileId, onSelectImage }: { fileId: string; onSelectImage?: (state: ModalState) => void }) {
const { data: imageData, isLoading: imageLoading } = useFileImage(fileId); const { data: imageData, isLoading: imageLoading } = useFileImage(fileId);
const { data: fileData } = useFile(fileId); const { data: fileData } = useFile(fileId);
const uri = imageData?.data?.url; const uri = imageData?.data?.url;
@@ -44,15 +46,17 @@ function DetailItem({ fileId, onSelectImage }: { fileId: string; onSelectImage?:
return ( return (
<ScrollView style={styles.page} contentContainerStyle={styles.pageContent}> <ScrollView style={styles.page} contentContainerStyle={styles.pageContent}>
{hasPages ? ( {hasPages ? (
fullThumbnails.map((thumb) => ( fullThumbnails.map((thumb, thumbIndex) => {
const allImages: SelectedImage[] = fullThumbnails.map((t) => ({
uri: t.url,
width: SCREEN_WIDTH,
height: t.height * (SCREEN_WIDTH / t.width),
}));
return (
<Pressable <Pressable
key={thumb.id} key={thumb.id}
style={styles.pageImageContainer} style={styles.pageImageContainer}
onPress={() => onSelectImage?.({ onPress={() => onSelectImage?.({ images: allImages, index: thumbIndex })}
uri: thumb.url,
width: SCREEN_WIDTH,
height: thumb.height * (SCREEN_WIDTH / thumb.width),
})}
> >
<Image <Image
source={{ uri: thumb.url }} source={{ uri: thumb.url }}
@@ -60,12 +64,13 @@ function DetailItem({ fileId, onSelectImage }: { fileId: string; onSelectImage?:
resizeMode="contain" resizeMode="contain"
/> />
</Pressable> </Pressable>
)) );
})
) : ( ) : (
<View style={styles.imageContainer}> <View style={styles.imageContainer}>
{uri ? ( {uri ? (
<Pressable <Pressable
onPress={() => onSelectImage?.({ uri, width: SCREEN_WIDTH, height: SCREEN_WIDTH })} onPress={() => onSelectImage?.({ images: [{ uri, width: SCREEN_WIDTH, height: SCREEN_WIDTH }], index: 0 })}
> >
<Image source={{ uri }} style={styles.image} resizeMode="contain" /> <Image source={{ uri }} style={styles.image} resizeMode="contain" />
</Pressable> </Pressable>
@@ -128,7 +133,18 @@ export function FileDetailScreen() {
const flatListRef = useRef<FlatList>(null); const flatListRef = useRef<FlatList>(null);
const [currentIndex, setCurrentIndex] = useState(initialIndex); const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [selectedImage, setSelectedImage] = useState<SelectedImage | null>(null); const [modalState, setModalState] = useState<ModalState>(null);
const handleSwipeVertical = useCallback((direction: 'up' | 'down') => {
setModalState((prev) => {
if (!prev) return prev;
const next = direction === 'up'
? Math.min(prev.index + 1, prev.images.length - 1)
: Math.max(prev.index - 1, 0);
if (next === prev.index) return prev;
return { ...prev, index: next };
});
}, []);
return ( return (
<View style={styles.container}> <View style={styles.container}>
@@ -153,7 +169,7 @@ export function FileDetailScreen() {
}} }}
renderItem={({ item }) => ( renderItem={({ item }) => (
<View style={styles.pageWrapper}> <View style={styles.pageWrapper}>
<DetailItem fileId={item} onSelectImage={setSelectedImage} /> <DetailItem fileId={item} onSelectImage={setModalState} />
</View> </View>
)} )}
/> />
@@ -165,20 +181,29 @@ export function FileDetailScreen() {
</View> </View>
<Modal <Modal
visible={selectedImage !== null} visible={modalState !== null}
transparent transparent
animationType="fade" animationType="fade"
statusBarTranslucent statusBarTranslucent
onRequestClose={() => setSelectedImage(null)} onRequestClose={() => setModalState(null)}
> >
{selectedImage && ( {modalState && (
<GestureHandlerRootView style={{ flex: 1 }}> <GestureHandlerRootView style={{ flex: 1 }}>
<ZoomableImage <ZoomableImage
uri={selectedImage.uri} key={modalState.images[modalState.index].uri}
width={selectedImage.width} uri={modalState.images[modalState.index].uri}
height={selectedImage.height} width={modalState.images[modalState.index].width}
onClose={() => setSelectedImage(null)} height={modalState.images[modalState.index].height}
onClose={() => setModalState(null)}
onSwipeVertical={handleSwipeVertical}
/> />
{modalState.images.length > 1 && (
<View style={styles.modalPagination}>
<Text style={styles.modalPaginationText}>
{modalState.index + 1} / {modalState.images.length}
</Text>
</View>
)}
</GestureHandlerRootView> </GestureHandlerRootView>
)} )}
</Modal> </Modal>
@@ -273,4 +298,17 @@ const styles = StyleSheet.create({
color: '#fff', color: '#fff',
fontSize: 13, fontSize: 13,
}, },
modalPagination: {
position: 'absolute',
bottom: 40,
alignSelf: 'center',
backgroundColor: 'rgba(0,0,0,0.5)',
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 4,
},
modalPaginationText: {
color: '#fff',
fontSize: 13,
},
}); });
+12 -2
View File
@@ -17,13 +17,14 @@ interface ZoomableImageProps {
width: number; width: number;
height: number; height: number;
onClose?: () => void; onClose?: () => void;
onSwipeVertical?: (direction: 'up' | 'down') => void;
} }
const MAX_SCALE = 4; const MAX_SCALE = 4;
const DOUBLE_TAP_SCALE = 2.5; const DOUBLE_TAP_SCALE = 2.5;
const SPRING_CONFIG = { damping: 20, stiffness: 200, mass: 0.5 }; const SPRING_CONFIG = { damping: 20, stiffness: 200, mass: 0.5 };
export function ZoomableImage({ uri, width, height, onClose }: ZoomableImageProps) { export function ZoomableImage({ uri, width, height, onClose, onSwipeVertical }: ZoomableImageProps) {
const scale = useSharedValue(1); const scale = useSharedValue(1);
const savedScale = useSharedValue(1); const savedScale = useSharedValue(1);
const translateX = useSharedValue(0); const translateX = useSharedValue(0);
@@ -35,6 +36,10 @@ export function ZoomableImage({ uri, width, height, onClose }: ZoomableImageProp
onClose?.(); onClose?.();
}, [onClose]); }, [onClose]);
const handleSwipeVertical = useCallback((direction: 'up' | 'down') => {
onSwipeVertical?.(direction);
}, [onSwipeVertical]);
const pinch = Gesture.Pinch() const pinch = Gesture.Pinch()
.onUpdate((e) => { .onUpdate((e) => {
scale.value = Math.min(Math.max(savedScale.value * e.scale, 1), MAX_SCALE); scale.value = Math.min(Math.max(savedScale.value * e.scale, 1), MAX_SCALE);
@@ -60,8 +65,13 @@ export function ZoomableImage({ uri, width, height, onClose }: ZoomableImageProp
translateY.value = savedTranslateY.value + e.translationY; translateY.value = savedTranslateY.value + e.translationY;
} }
}) })
.onEnd(() => { .onEnd((e) => {
if (savedScale.value <= 1) { if (savedScale.value <= 1) {
const absY = Math.abs(e.translationY);
const absX = Math.abs(e.translationX);
if (absY > 30 && absY > absX) {
runOnJS(handleSwipeVertical)(e.translationY > 0 ? 'down' : 'up');
}
translateX.value = withSpring(0, SPRING_CONFIG); translateX.value = withSpring(0, SPRING_CONFIG);
translateY.value = withSpring(0, SPRING_CONFIG); translateY.value = withSpring(0, SPRING_CONFIG);
} }