Initial frontend repository commit.
Include app source and update .gitignore to exclude local release artifacts and signing files. Made-with: Cursor
This commit is contained in:
609
src/components/common/ImageGallery.tsx
Normal file
609
src/components/common/ImageGallery.tsx
Normal file
@@ -0,0 +1,609 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user