Files
frontend/src/components/common/ImageGallery.tsx
lan 3968660048 Initial frontend repository commit.
Include app source and update .gitignore to exclude local release artifacts and signing files.

Made-with: Cursor
2026-03-09 21:29:03 +08:00

610 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;