fix zoom image

This commit is contained in:
m
2026-07-16 09:27:43 +02:00
parent 2dac600d06
commit 04e74316f9
2 changed files with 131 additions and 42 deletions
+55 -22
View File
@@ -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,
+76 -20
View File
@@ -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' }],
},
}); });