zoomable image
This commit is contained in:
+67
-29
@@ -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,28 +46,31 @@ 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) => {
|
||||||
<Pressable
|
const allImages: SelectedImage[] = fullThumbnails.map((t) => ({
|
||||||
key={thumb.id}
|
uri: t.url,
|
||||||
style={styles.pageImageContainer}
|
width: SCREEN_WIDTH,
|
||||||
onPress={() => onSelectImage?.({
|
height: t.height * (SCREEN_WIDTH / t.width),
|
||||||
uri: thumb.url,
|
}));
|
||||||
width: SCREEN_WIDTH,
|
return (
|
||||||
height: thumb.height * (SCREEN_WIDTH / thumb.width),
|
<Pressable
|
||||||
})}
|
key={thumb.id}
|
||||||
>
|
style={styles.pageImageContainer}
|
||||||
<Image
|
onPress={() => onSelectImage?.({ images: allImages, index: thumbIndex })}
|
||||||
source={{ uri: thumb.url }}
|
>
|
||||||
style={[styles.pageImage, { height: thumb.height * (SCREEN_WIDTH / thumb.width) }]}
|
<Image
|
||||||
resizeMode="contain"
|
source={{ uri: thumb.url }}
|
||||||
/>
|
style={[styles.pageImage, { height: thumb.height * (SCREEN_WIDTH / thumb.width) }]}
|
||||||
</Pressable>
|
resizeMode="contain"
|
||||||
))
|
/>
|
||||||
|
</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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user