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;
|