/** * 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 = ({ 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 ( {/* 顶部控制栏 */} {showControls && ( {currentIndex + 1} / {validImages.length} {enableSave && ( {saving ? ( ) : ( )} )} )} {/* 图片显示区域 */} {loading && ( )} {error && ( 加载失败 )} {currentImage && ( setLoading(true)} onLoad={() => { setLoading(false); setError(false); }} onError={() => { setLoading(false); setError(true); }} /> )} {/* 左右切换按钮 */} {showControls && validImages.length > 1 && ( <> {currentIndex > 0 && ( )} {currentIndex < validImages.length - 1 && ( )} )} {/* 底部指示器 */} {showControls && showIndicator && validImages.length > 1 && ( {validImages.map((_, index) => ( ))} )} {/* 保存结果 Toast */} {saveToast !== null && ( {saveToast === 'success' ? '已保存到相册' : '保存失败,请重试'} )} ); }; 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;