fix zoom image
This commit is contained in:
+55
-22
@@ -1,29 +1,35 @@
|
|||||||
import React, { useRef, useState, useCallback } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
FlatList,
|
FlatList,
|
||||||
|
Image,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { RouteProp, useRoute } from '@react-navigation/native';
|
import { RouteProp, useRoute } from '@react-navigation/native';
|
||||||
import { useFile, useFileImage } from '../hooks/useFiles';
|
import { useFile, useFileImage } from '../hooks/useFiles';
|
||||||
import { TagChip } from '../components/TagChip';
|
import { TagChip } from '../components/TagChip';
|
||||||
import { FileThumbnail } from '../components/FileThumbnail';
|
import { FileThumbnail } from '../components/FileThumbnail';
|
||||||
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
import { ZoomableImage } from '../components/ZoomableImage';
|
import { ZoomableImage } from '../components/ZoomableImage';
|
||||||
import type { Thumbnail } from '../types';
|
import type { Thumbnail } from '../types';
|
||||||
|
|
||||||
const SCREEN_WIDTH = Dimensions.get('window').width;
|
const SCREEN_WIDTH = Dimensions.get('window').width;
|
||||||
|
|
||||||
|
type SelectedImage = { uri: string; width: number; height: number };
|
||||||
|
|
||||||
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, onScaleChange }: { fileId: string; onScaleChange?: (scale: number) => void }) {
|
function DetailItem({ fileId, onSelectImage }: { fileId: string; onSelectImage?: (img: SelectedImage) => 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;
|
||||||
@@ -39,24 +45,30 @@ function DetailItem({ fileId, onScaleChange }: { fileId: string; onScaleChange?:
|
|||||||
<ScrollView style={styles.page} contentContainerStyle={styles.pageContent}>
|
<ScrollView style={styles.page} contentContainerStyle={styles.pageContent}>
|
||||||
{hasPages ? (
|
{hasPages ? (
|
||||||
fullThumbnails.map((thumb) => (
|
fullThumbnails.map((thumb) => (
|
||||||
<View key={thumb.id} style={styles.pageImageContainer}>
|
<Pressable
|
||||||
<ZoomableImage
|
key={thumb.id}
|
||||||
uri={thumb.url}
|
style={styles.pageImageContainer}
|
||||||
width={SCREEN_WIDTH}
|
onPress={() => onSelectImage?.({
|
||||||
height={thumb.height * (SCREEN_WIDTH / thumb.width)}
|
uri: thumb.url,
|
||||||
onScaleChange={onScaleChange}
|
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"
|
||||||
/>
|
/>
|
||||||
</View>
|
</Pressable>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.imageContainer}>
|
<View style={styles.imageContainer}>
|
||||||
{uri ? (
|
{uri ? (
|
||||||
<ZoomableImage
|
<Pressable
|
||||||
uri={uri}
|
onPress={() => onSelectImage?.({ uri, width: SCREEN_WIDTH, height: SCREEN_WIDTH })}
|
||||||
width={SCREEN_WIDTH}
|
>
|
||||||
height={SCREEN_WIDTH}
|
<Image source={{ uri }} style={styles.image} resizeMode="contain" />
|
||||||
onScaleChange={onScaleChange}
|
</Pressable>
|
||||||
/>
|
|
||||||
) : file ? (
|
) : file ? (
|
||||||
<FileThumbnail
|
<FileThumbnail
|
||||||
thumbnailUrl={file.data?.thumbnailUrl}
|
thumbnailUrl={file.data?.thumbnailUrl}
|
||||||
@@ -116,11 +128,7 @@ export function FileDetailScreen() {
|
|||||||
|
|
||||||
const flatListRef = useRef<FlatList>(null);
|
const flatListRef = useRef<FlatList>(null);
|
||||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||||
const [scrollEnabled, setScrollEnabled] = useState(true);
|
const [selectedImage, setSelectedImage] = useState<SelectedImage | null>(null);
|
||||||
|
|
||||||
const handleScaleChange = useCallback((scale: number) => {
|
|
||||||
setScrollEnabled(scale <= 1);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
@@ -134,7 +142,6 @@ export function FileDetailScreen() {
|
|||||||
disableIntervalMomentum
|
disableIntervalMomentum
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
initialScrollIndex={initialIndex}
|
initialScrollIndex={initialIndex}
|
||||||
scrollEnabled={scrollEnabled}
|
|
||||||
getItemLayout={(_, index) => ({
|
getItemLayout={(_, index) => ({
|
||||||
length: SCREEN_WIDTH,
|
length: SCREEN_WIDTH,
|
||||||
offset: SCREEN_WIDTH * index,
|
offset: SCREEN_WIDTH * index,
|
||||||
@@ -146,7 +153,7 @@ export function FileDetailScreen() {
|
|||||||
}}
|
}}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<View style={styles.pageWrapper}>
|
<View style={styles.pageWrapper}>
|
||||||
<DetailItem fileId={item} onScaleChange={handleScaleChange} />
|
<DetailItem fileId={item} onSelectImage={setSelectedImage} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -156,6 +163,25 @@ export function FileDetailScreen() {
|
|||||||
{currentIndex + 1} / {fileIds.length}
|
{currentIndex + 1} / {fileIds.length}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={selectedImage !== null}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
statusBarTranslucent
|
||||||
|
onRequestClose={() => setSelectedImage(null)}
|
||||||
|
>
|
||||||
|
{selectedImage && (
|
||||||
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
|
<ZoomableImage
|
||||||
|
uri={selectedImage.uri}
|
||||||
|
width={selectedImage.width}
|
||||||
|
height={selectedImage.height}
|
||||||
|
onClose={() => setSelectedImage(null)}
|
||||||
|
/>
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -180,11 +206,18 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
backgroundColor: '#000',
|
backgroundColor: '#000',
|
||||||
},
|
},
|
||||||
|
image: {
|
||||||
|
width: SCREEN_WIDTH,
|
||||||
|
height: SCREEN_WIDTH,
|
||||||
|
},
|
||||||
pageImageContainer: {
|
pageImageContainer: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
backgroundColor: '#000',
|
backgroundColor: '#000',
|
||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
},
|
},
|
||||||
|
pageImage: {
|
||||||
|
width: SCREEN_WIDTH,
|
||||||
|
},
|
||||||
details: {
|
details: {
|
||||||
backgroundColor: '#fff',
|
backgroundColor: '#fff',
|
||||||
borderTopLeftRadius: 16,
|
borderTopLeftRadius: 16,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { StyleSheet } from 'react-native';
|
import { StyleSheet, Pressable, View } from 'react-native';
|
||||||
import Animated, {
|
import Animated, {
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
@@ -16,14 +16,14 @@ interface ZoomableImageProps {
|
|||||||
uri: string;
|
uri: string;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
onScaleChange?: (scale: number) => void;
|
onClose?: () => 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, onScaleChange }: ZoomableImageProps) {
|
export function ZoomableImage({ uri, width, height, onClose }: 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);
|
||||||
@@ -31,14 +31,13 @@ export function ZoomableImage({ uri, width, height, onScaleChange }: ZoomableIma
|
|||||||
const savedTranslateX = useSharedValue(0);
|
const savedTranslateX = useSharedValue(0);
|
||||||
const savedTranslateY = useSharedValue(0);
|
const savedTranslateY = useSharedValue(0);
|
||||||
|
|
||||||
const notifyScale = useCallback((s: number) => {
|
const handleClose = useCallback(() => {
|
||||||
onScaleChange?.(s);
|
onClose?.();
|
||||||
}, [onScaleChange]);
|
}, [onClose]);
|
||||||
|
|
||||||
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);
|
||||||
runOnJS(notifyScale)(scale.value);
|
|
||||||
})
|
})
|
||||||
.onEnd(() => {
|
.onEnd(() => {
|
||||||
if (scale.value < 1) {
|
if (scale.value < 1) {
|
||||||
@@ -48,15 +47,13 @@ export function ZoomableImage({ uri, width, height, onScaleChange }: ZoomableIma
|
|||||||
savedScale.value = 1;
|
savedScale.value = 1;
|
||||||
savedTranslateX.value = 0;
|
savedTranslateX.value = 0;
|
||||||
savedTranslateY.value = 0;
|
savedTranslateY.value = 0;
|
||||||
runOnJS(notifyScale)(1);
|
|
||||||
} else {
|
} else {
|
||||||
savedScale.value = scale.value;
|
savedScale.value = scale.value;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const pan = Gesture.Pan()
|
const pan = Gesture.Pan()
|
||||||
.minDistance(10)
|
.minDistance(5)
|
||||||
.activeOffsetX([-15, 15])
|
|
||||||
.onUpdate((e) => {
|
.onUpdate((e) => {
|
||||||
if (savedScale.value > 1) {
|
if (savedScale.value > 1) {
|
||||||
translateX.value = savedTranslateX.value + e.translationX;
|
translateX.value = savedTranslateX.value + e.translationX;
|
||||||
@@ -72,6 +69,8 @@ export function ZoomableImage({ uri, width, height, onScaleChange }: ZoomableIma
|
|||||||
savedTranslateY.value = translateY.value;
|
savedTranslateY.value = translateY.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const zoomGestures = Gesture.Simultaneous(pinch, pan);
|
||||||
|
|
||||||
const doubleTap = Gesture.Tap()
|
const doubleTap = Gesture.Tap()
|
||||||
.numberOfTaps(2)
|
.numberOfTaps(2)
|
||||||
.maxDuration(250)
|
.maxDuration(250)
|
||||||
@@ -83,15 +82,23 @@ export function ZoomableImage({ uri, width, height, onScaleChange }: ZoomableIma
|
|||||||
savedScale.value = 1;
|
savedScale.value = 1;
|
||||||
savedTranslateX.value = 0;
|
savedTranslateX.value = 0;
|
||||||
savedTranslateY.value = 0;
|
savedTranslateY.value = 0;
|
||||||
runOnJS(notifyScale)(1);
|
|
||||||
} else {
|
} else {
|
||||||
scale.value = withTiming(DOUBLE_TAP_SCALE, { duration: 200 });
|
scale.value = withTiming(DOUBLE_TAP_SCALE, { duration: 200 });
|
||||||
savedScale.value = DOUBLE_TAP_SCALE;
|
savedScale.value = DOUBLE_TAP_SCALE;
|
||||||
runOnJS(notifyScale)(DOUBLE_TAP_SCALE);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const composed = Gesture.Exclusive(pinch, doubleTap, pan);
|
const singleTap = Gesture.Tap()
|
||||||
|
.maxDuration(250)
|
||||||
|
.onEnd(() => {
|
||||||
|
if (scale.value <= 1) {
|
||||||
|
runOnJS(handleClose)();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const taps = Gesture.Exclusive(doubleTap, singleTap);
|
||||||
|
|
||||||
|
const composed = Gesture.Race(zoomGestures, taps);
|
||||||
|
|
||||||
const animatedStyle = useAnimatedStyle(() => ({
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
transform: [
|
transform: [
|
||||||
@@ -102,18 +109,67 @@ export function ZoomableImage({ uri, width, height, onScaleChange }: ZoomableIma
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureDetector gesture={composed}>
|
<View style={styles.container}>
|
||||||
<Animated.Image
|
<GestureDetector gesture={composed}>
|
||||||
source={{ uri }}
|
<Animated.Image
|
||||||
style={[styles.image, { width, height }, animatedStyle]}
|
source={{ uri }}
|
||||||
resizeMode="contain"
|
style={[styles.image, { width, height }, animatedStyle]}
|
||||||
/>
|
resizeMode="contain"
|
||||||
</GestureDetector>
|
/>
|
||||||
|
</GestureDetector>
|
||||||
|
{onClose && (
|
||||||
|
<Pressable style={styles.closeButton} onPress={onClose}>
|
||||||
|
<View style={styles.closeIcon}>
|
||||||
|
<View style={[styles.closeLine, styles.closeLine1]} />
|
||||||
|
<View style={[styles.closeLine, styles.closeLine2]} />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CLOSE_SIZE = 36;
|
||||||
|
const CLOSE_LINE = 20;
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#000',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
image: {
|
image: {
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
|
closeButton: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 56,
|
||||||
|
right: 16,
|
||||||
|
width: CLOSE_SIZE,
|
||||||
|
height: CLOSE_SIZE,
|
||||||
|
borderRadius: CLOSE_SIZE / 2,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.25)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
closeIcon: {
|
||||||
|
width: CLOSE_LINE,
|
||||||
|
height: CLOSE_LINE,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
closeLine: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: CLOSE_LINE,
|
||||||
|
height: 2,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderRadius: 1,
|
||||||
|
},
|
||||||
|
closeLine1: {
|
||||||
|
transform: [{ rotate: '45deg' }],
|
||||||
|
},
|
||||||
|
closeLine2: {
|
||||||
|
transform: [{ rotate: '-45deg' }],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user