Files
frontend/src/components/common/ImageGallery.tsx

610 lines
18 KiB
TypeScript
Raw Normal View History

/**
* ImageGallery
*
* 使 expo-image GIF/WebP
*/
import React, { useState, useCallback, useEffect, useMemo } from 'react';
import {
Modal,
View,
StyleSheet,
Dimensions,
TouchableOpacity,
Text,
StatusBar,
ActivityIndicator,
Alert,
} from 'react-native';
import { Image as ExpoImage } from 'expo-image';
import {
Gesture,
GestureDetector,
GestureHandlerRootView,
} from 'react-native-gesture-handler';
import Animated, {
useSharedValue,
useAnimatedStyle,
useDerivedValue,
runOnJS,
withTiming,
withSpring,
} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as MediaLibrary from 'expo-media-library';
import { File, Paths } from 'expo-file-system';
import { colors, spacing, borderRadius, fontSizes } from '../../theme';
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
// 图片项类型
export interface GalleryImageItem {
id: string;
url: string;
thumbnailUrl?: string;
width?: number;
height?: number;
description?: string;
}
// 组件 Props
export interface ImageGalleryProps {
/** 是否可见 */
visible: boolean;
/** 图片列表 */
images: GalleryImageItem[];
/** 初始索引 */
initialIndex: number;
/** 关闭回调 */
onClose: () => void;
/** 索引变化回调 */
onIndexChange?: (index: number) => void;
/** 是否显示指示器 */
showIndicator?: boolean;
/** 是否允许保存图片 */
enableSave?: boolean;
/** 保存图片成功回调 */
onSave?: (url: string) => void;
/** 背景透明度 */
backgroundOpacity?: number;
}
/**
*
*/
export const ImageGallery: React.FC<ImageGalleryProps> = ({
visible,
images,
initialIndex,
onClose,
onIndexChange,
showIndicator = true,
enableSave = false,
onSave,
backgroundOpacity = 1,
}) => {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [showControls, setShowControls] = useState(true);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [saving, setSaving] = useState(false);
const [saveToast, setSaveToast] = useState<'success' | 'error' | null>(null);
const insets = useSafeAreaInsets();
// 缩放相关状态
const scale = useSharedValue(1);
const savedScale = useSharedValue(1);
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const savedTranslateX = useSharedValue(0);
const savedTranslateY = useSharedValue(0);
const validImages = useMemo(() => {
return images.filter(img => img.url);
}, [images]);
const currentImage = useMemo(() => {
if (currentIndex < 0 || currentIndex >= validImages.length) {
return null;
}
return validImages[currentIndex];
}, [validImages, currentIndex]);
// 重置缩放状态
const resetZoom = useCallback(() => {
scale.value = withTiming(1, { duration: 200 });
savedScale.value = 1;
translateX.value = withTiming(0, { duration: 200 });
translateY.value = withTiming(0, { duration: 200 });
savedTranslateX.value = 0;
savedTranslateY.value = 0;
}, [scale, savedScale, translateX, translateY, savedTranslateX, savedTranslateY]);
// 打开/关闭时重置状态
useEffect(() => {
if (visible) {
setCurrentIndex(initialIndex);
setShowControls(true);
setLoading(true);
setError(false);
resetZoom();
StatusBar.setHidden(true, 'fade');
} else {
StatusBar.setHidden(false, 'fade');
}
}, [visible, initialIndex, resetZoom]);
// 图片变化时重置加载状态和缩放
useEffect(() => {
setLoading(true);
setError(false);
resetZoom();
}, [currentImage?.id, resetZoom]);
const updateIndex = useCallback(
(newIndex: number) => {
const clampedIndex = Math.max(0, Math.min(validImages.length - 1, newIndex));
setCurrentIndex(clampedIndex);
onIndexChange?.(clampedIndex);
},
[validImages.length, onIndexChange]
);
const toggleControls = useCallback(() => {
setShowControls(prev => !prev);
}, []);
const goToPrev = useCallback(() => {
if (currentIndex > 0) {
updateIndex(currentIndex - 1);
}
}, [currentIndex, updateIndex]);
const goToNext = useCallback(() => {
if (currentIndex < validImages.length - 1) {
updateIndex(currentIndex + 1);
}
}, [currentIndex, validImages.length, updateIndex]);
// 显示短暂提示
const showToast = useCallback((type: 'success' | 'error') => {
setSaveToast(type);
setTimeout(() => setSaveToast(null), 2500);
}, []);
// 保存图片到本地相册
const handleSaveImage = useCallback(async () => {
if (!currentImage || saving) return;
const { status } = await MediaLibrary.requestPermissionsAsync();
if (status !== 'granted') {
Alert.alert('无法保存', '请在系统设置中允许访问相册权限后重试。');
return;
}
setSaving(true);
try {
const urlPath = currentImage.url.split('?')[0];
const ext = urlPath.split('.').pop()?.toLowerCase() ?? 'jpg';
const allowedExts = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
const fileExt = allowedExts.includes(ext) ? ext : 'jpg';
const fileName = `carrot_${Date.now()}.${fileExt}`;
const destination = new File(Paths.cache, fileName);
// File.downloadFileAsync 是新版 expo-file-system/next 的静态方法
const downloaded = await File.downloadFileAsync(currentImage.url, destination);
await MediaLibrary.saveToLibraryAsync(downloaded.uri);
// 清理缓存文件
downloaded.delete();
onSave?.(currentImage.url);
showToast('success');
} catch (err) {
console.error('[ImageGallery] 保存图片失败:', err);
showToast('error');
} finally {
setSaving(false);
}
}, [currentImage, saving, onSave, showToast]);
// 双指缩放手势
const pinchGesture = Gesture.Pinch()
.onUpdate((e) => {
const newScale = savedScale.value * e.scale;
// 限制缩放范围
scale.value = Math.max(0.5, Math.min(newScale, 4));
})
.onEnd(() => {
// 如果缩放小于1回弹到1
if (scale.value < 1) {
scale.value = withTiming(1, { duration: 200 });
savedScale.value = 1;
translateX.value = withTiming(0, { duration: 200 });
translateY.value = withTiming(0, { duration: 200 });
savedTranslateX.value = 0;
savedTranslateY.value = 0;
} else {
savedScale.value = scale.value;
}
});
// 滑动切换图片相关状态
const swipeTranslateX = useSharedValue(0);
// 统一的滑动手势:放大时拖动,未放大时切换图片
const panGesture = Gesture.Pan()
.activeOffsetX([-10, 10]) // 水平方向需要移动10pt才激活避免与点击冲突
.activeOffsetY([-10, 10]) // 垂直方向也需要一定偏移才激活
.onBegin(() => {
swipeTranslateX.value = 0;
})
.onUpdate((e) => {
if (scale.value > 1) {
// 放大状态下:拖动图片
translateX.value = savedTranslateX.value + e.translationX;
translateY.value = savedTranslateY.value + e.translationY;
} else if (validImages.length > 1) {
// 未放大且有多张图片:切换图片的跟随效果
const isFirst = currentIndex === 0 && e.translationX > 0;
const isLast = currentIndex === validImages.length - 1 && e.translationX < 0;
if (isFirst || isLast) {
// 边界阻力效果
swipeTranslateX.value = e.translationX * 0.3;
} else {
swipeTranslateX.value = e.translationX;
}
}
})
.onEnd((e) => {
if (scale.value > 1) {
// 放大状态下:保存拖动位置
savedTranslateX.value = translateX.value;
savedTranslateY.value = translateY.value;
} else if (validImages.length > 1) {
// 未放大状态下:判断是否切换图片
const threshold = SCREEN_WIDTH * 0.2;
const velocity = e.velocityX;
const shouldGoNext = e.translationX < -threshold || velocity < -500;
const shouldGoPrev = e.translationX > threshold || velocity > 500;
if (shouldGoNext && currentIndex < validImages.length - 1) {
// 向左滑动,显示下一张
swipeTranslateX.value = withTiming(-SCREEN_WIDTH, { duration: 200 }, () => {
runOnJS(updateIndex)(currentIndex + 1);
swipeTranslateX.value = 0;
});
} else if (shouldGoPrev && currentIndex > 0) {
// 向右滑动,显示上一张
swipeTranslateX.value = withTiming(SCREEN_WIDTH, { duration: 200 }, () => {
runOnJS(updateIndex)(currentIndex - 1);
swipeTranslateX.value = 0;
});
} else {
// 回弹到原位
swipeTranslateX.value = withTiming(0, { duration: 200 });
}
}
});
// 点击手势(关闭 gallery
const tapGesture = Gesture.Tap()
.numberOfTaps(1)
.maxDistance(10)
.onEnd(() => {
runOnJS(onClose)();
});
// 组合手势:
// - pinchGesture 和 (panGesture / tapGesture) 可以同时识别
// - panGesture 和 tapGesture 互斥Race短按是点击长按/滑动是拖动
const composedGesture = Gesture.Simultaneous(
pinchGesture,
Gesture.Race(panGesture, tapGesture)
);
// 动画样式
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: translateX.value + swipeTranslateX.value },
{ translateY: translateY.value },
{ scale: scale.value },
] as const,
}));
if (!visible || validImages.length === 0) {
return null;
}
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
statusBarTranslucent
>
<GestureHandlerRootView style={styles.root}>
<View style={[styles.container, { backgroundColor: `rgba(0, 0, 0, ${backgroundOpacity})` }]}>
{/* 顶部控制栏 */}
{showControls && (
<View style={[styles.header, { paddingTop: insets.top + spacing.md }]}>
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
<MaterialCommunityIcons name="close" size={24} color="#FFF" />
</TouchableOpacity>
<View style={styles.headerCenter}>
<Text style={styles.pageIndicator}>
{currentIndex + 1} / {validImages.length}
</Text>
</View>
{enableSave && (
<TouchableOpacity
style={styles.saveButton}
onPress={handleSaveImage}
disabled={saving}
>
{saving ? (
<ActivityIndicator size="small" color="#FFF" />
) : (
<MaterialCommunityIcons name="download" size={24} color="#FFF" />
)}
</TouchableOpacity>
)}
</View>
)}
{/* 图片显示区域 */}
<GestureDetector gesture={composedGesture}>
<View style={styles.imageContainer}>
{loading && (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#FFF" />
</View>
)}
{error && (
<View style={styles.errorContainer}>
<MaterialCommunityIcons name="image-off" size={48} color="#999" />
<Text style={styles.errorText}></Text>
</View>
)}
{currentImage && (
<Animated.View style={[styles.imageWrapper, animatedStyle]}>
<ExpoImage
source={{ uri: currentImage.url }}
style={styles.image}
contentFit="contain"
cachePolicy="disk"
priority="high"
recyclingKey={currentImage.id}
allowDownscaling
onLoadStart={() => setLoading(true)}
onLoad={() => {
setLoading(false);
setError(false);
}}
onError={() => {
setLoading(false);
setError(true);
}}
/>
</Animated.View>
)}
</View>
</GestureDetector>
{/* 左右切换按钮 */}
{showControls && validImages.length > 1 && (
<>
{currentIndex > 0 && (
<TouchableOpacity
style={[styles.navButton, styles.navButtonLeft]}
onPress={goToPrev}
activeOpacity={0.7}
>
<MaterialCommunityIcons name="chevron-left" size={36} color="#FFF" />
</TouchableOpacity>
)}
{currentIndex < validImages.length - 1 && (
<TouchableOpacity
style={[styles.navButton, styles.navButtonRight]}
onPress={goToNext}
activeOpacity={0.7}
>
<MaterialCommunityIcons name="chevron-right" size={36} color="#FFF" />
</TouchableOpacity>
)}
</>
)}
{/* 底部指示器 */}
{showControls && showIndicator && validImages.length > 1 && (
<View style={[styles.footer, { paddingBottom: insets.bottom + spacing.lg }]}>
<View style={styles.dotsContainer}>
{validImages.map((_, index) => (
<View
key={index}
style={[
styles.dot,
index === currentIndex && styles.activeDot,
]}
/>
))}
</View>
</View>
)}
{/* 保存结果 Toast */}
{saveToast !== null && (
<View style={[styles.toast, saveToast === 'success' ? styles.toastSuccess : styles.toastError]}>
<MaterialCommunityIcons
name={saveToast === 'success' ? 'check-circle-outline' : 'alert-circle-outline'}
size={18}
color="#FFF"
/>
<Text style={styles.toastText}>
{saveToast === 'success' ? '已保存到相册' : '保存失败,请重试'}
</Text>
</View>
)}
</View>
</GestureHandlerRootView>
</Modal>
);
};
const styles = StyleSheet.create({
root: {
flex: 1,
},
container: {
flex: 1,
backgroundColor: '#000',
},
header: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.md,
paddingBottom: spacing.md,
backgroundColor: 'rgba(0, 0, 0, 0.3)',
zIndex: 10,
},
closeButton: {
width: 40,
height: 40,
borderRadius: borderRadius.full,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
justifyContent: 'center',
alignItems: 'center',
},
headerCenter: {
flex: 1,
alignItems: 'center',
},
pageIndicator: {
color: '#FFF',
fontSize: fontSizes.md,
fontWeight: '500',
},
saveButton: {
width: 40,
height: 40,
borderRadius: borderRadius.full,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
justifyContent: 'center',
alignItems: 'center',
},
imageContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
imageWrapper: {
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT,
justifyContent: 'center',
alignItems: 'center',
},
image: {
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT * 0.8,
},
loadingContainer: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
zIndex: 5,
},
errorContainer: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
},
errorText: {
color: '#999',
fontSize: fontSizes.md,
marginTop: spacing.sm,
},
navButton: {
position: 'absolute',
top: '50%',
marginTop: -25,
width: 50,
height: 50,
borderRadius: 25,
backgroundColor: 'rgba(0, 0, 0, 0.4)',
justifyContent: 'center',
alignItems: 'center',
zIndex: 10,
},
navButtonLeft: {
left: spacing.sm,
},
navButtonRight: {
right: spacing.sm,
},
footer: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
paddingVertical: spacing.lg,
backgroundColor: 'rgba(0, 0, 0, 0.3)',
},
dotsContainer: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: 8,
},
dot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: 'rgba(255, 255, 255, 0.4)',
},
activeDot: {
width: 20,
borderRadius: 4,
backgroundColor: '#FFF',
},
toast: {
position: 'absolute',
bottom: 100,
alignSelf: 'center',
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: borderRadius.full,
},
toastSuccess: {
backgroundColor: 'rgba(34, 197, 94, 0.9)',
},
toastError: {
backgroundColor: 'rgba(239, 68, 68, 0.9)',
},
toastText: {
color: '#FFF',
fontSize: fontSizes.sm,
fontWeight: '500',
},
});
export default ImageGallery;