Include app source and update .gitignore to exclude local release artifacts and signing files. Made-with: Cursor
610 lines
18 KiB
TypeScript
610 lines
18 KiB
TypeScript
/**
|
||
* 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;
|