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
+67 -29
View File
@@ -1,4 +1,4 @@
import React, { useRef, useState } from 'react';
import React, { useRef, useState, useCallback } from 'react';
import {
View,
Text,
@@ -23,13 +23,15 @@ const SCREEN_WIDTH = Dimensions.get('window').width;
type SelectedImage = { uri: string; width: number; height: number };
type ModalState = { images: SelectedImage[]; index: number } | null;
type RootStackParamList = {
FileDetail: { fileIds: string[]; initialIndex: number };
};
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: fileData } = useFile(fileId);
const uri = imageData?.data?.url;
@@ -44,28 +46,31 @@ function DetailItem({ fileId, onSelectImage }: { fileId: string; onSelectImage?:
return (
<ScrollView style={styles.page} contentContainerStyle={styles.pageContent}>
{hasPages ? (
fullThumbnails.map((thumb) => (
<Pressable
key={thumb.id}
style={styles.pageImageContainer}
onPress={() => onSelectImage?.({
uri: thumb.url,
width: SCREEN_WIDTH,
height: thumb.height * (SCREEN_WIDTH / thumb.width),
})}
>
<Image
source={{ uri: thumb.url }}
style={[styles.pageImage, { height: thumb.height * (SCREEN_WIDTH / thumb.width) }]}
resizeMode="contain"
/>
</Pressable>
))
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
key={thumb.id}
style={styles.pageImageContainer}
onPress={() => onSelectImage?.({ images: allImages, index: thumbIndex })}
>
<Image
source={{ uri: thumb.url }}
style={[styles.pageImage, { height: thumb.height * (SCREEN_WIDTH / thumb.width) }]}
resizeMode="contain"
/>
</Pressable>
);
})
) : (
<View style={styles.imageContainer}>
{uri ? (
<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" />
</Pressable>
@@ -128,7 +133,18 @@ export function FileDetailScreen() {
const flatListRef = useRef<FlatList>(null);
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 (
<View style={styles.container}>
@@ -153,7 +169,7 @@ export function FileDetailScreen() {
}}
renderItem={({ item }) => (
<View style={styles.pageWrapper}>
<DetailItem fileId={item} onSelectImage={setSelectedImage} />
<DetailItem fileId={item} onSelectImage={setModalState} />
</View>
)}
/>
@@ -165,20 +181,29 @@ export function FileDetailScreen() {
</View>
<Modal
visible={selectedImage !== null}
visible={modalState !== null}
transparent
animationType="fade"
statusBarTranslucent
onRequestClose={() => setSelectedImage(null)}
onRequestClose={() => setModalState(null)}
>
{selectedImage && (
{modalState && (
<GestureHandlerRootView style={{ flex: 1 }}>
<ZoomableImage
uri={selectedImage.uri}
width={selectedImage.width}
height={selectedImage.height}
onClose={() => setSelectedImage(null)}
key={modalState.images[modalState.index].uri}
uri={modalState.images[modalState.index].uri}
width={modalState.images[modalState.index].width}
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>
)}
</Modal>
@@ -273,4 +298,17 @@ const styles = StyleSheet.create({
color: '#fff',
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;
height: number;
onClose?: () => void;
onSwipeVertical?: (direction: 'up' | 'down') => void;
}
const MAX_SCALE = 4;
const DOUBLE_TAP_SCALE = 2.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 savedScale = useSharedValue(1);
const translateX = useSharedValue(0);
@@ -35,6 +36,10 @@ export function ZoomableImage({ uri, width, height, onClose }: ZoomableImageProp
onClose?.();
}, [onClose]);
const handleSwipeVertical = useCallback((direction: 'up' | 'down') => {
onSwipeVertical?.(direction);
}, [onSwipeVertical]);
const pinch = Gesture.Pinch()
.onUpdate((e) => {
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;
}
})
.onEnd(() => {
.onEnd((e) => {
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);
translateY.value = withSpring(0, SPRING_CONFIG);
}