Files
frontend/src/components/common/ImageGallery.tsx
lafay a921aacefd refactor: streamline post sync, fix image gallery, and clean up chat screen
- **Post sync optimization**: Clear store immediately when params change to prevent flashing old posts; replace instead of merge on refresh
- **ImageGallery fix**: Use idempotent download option, migrate to Asset.create() API, fix Android file path requirement, ensure proper cleanup
- **UserProfileScreen**: Add ImageGallery for post image viewing with consistent implementation
- **SearchScreen**: Add entrance animation and empty state
- **ChatScreen**: Remove unused state variables (lastSeq, firstSeq, isProgrammaticScrollRef) and dead imports
2026-06-18 02:29:54 +08:00

640 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, useRef } from 'react';
import {
Modal,
View,
StyleSheet,
Dimensions,
TouchableOpacity,
Text,
StatusBar,
Alert,
Platform,
} from 'react-native';
import { Image as ExpoImage } from 'expo-image';
import {
Gesture,
GestureDetector,
GestureHandlerRootView,
} from 'react-native-gesture-handler';
import Animated, {
useSharedValue,
useAnimatedStyle,
runOnJS,
withTiming,
} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { spacing, borderRadius, fontSizes } from '../../theme';
import { blurActiveElement } from '../../infrastructure/platform';
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 closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isClosingRef = useRef(false);
const currentIndexRef = useRef(initialIndex);
const pendingSwipeDirectionRef = useRef<-1 | 1 | null>(null);
const [currentIndex, setCurrentIndex] = useState(initialIndex);
currentIndexRef.current = currentIndex;
const [showControls, setShowControls] = 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) {
blurActiveElement();
setCurrentIndex(initialIndex);
setShowControls(true);
setError(false);
resetZoom();
StatusBar.setHidden(true, 'fade');
} else {
StatusBar.setHidden(false, 'fade');
}
}, [visible, initialIndex, resetZoom]);
useEffect(() => {
if (visible) {
isClosingRef.current = false;
}
}, [visible]);
useEffect(() => {
return () => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
}
};
}, []);
// 图片变化时重置缩放(加载态在 updateIndex / 打开弹窗时同步设置,避免晚一帧仍显示上一张)
useEffect(() => {
resetZoom();
}, [currentImage?.id, resetZoom]);
const updateIndex = useCallback(
(newIndex: number) => {
const clampedIndex = Math.max(0, Math.min(validImages.length - 1, newIndex));
if (clampedIndex === currentIndexRef.current) {
return;
}
setError(false);
setCurrentIndex(clampedIndex);
onIndexChange?.(clampedIndex);
},
[validImages.length, onIndexChange]
);
const updateIndexFromSwipe = useCallback(
(newIndex: number, direction: -1 | 1) => {
pendingSwipeDirectionRef.current = direction;
updateIndex(newIndex);
},
[updateIndex]
);
const toggleControls = useCallback(() => {
setShowControls(prev => !prev);
}, []);
const requestClose = useCallback(() => {
if (isClosingRef.current) {
return;
}
isClosingRef.current = true;
// 延迟一个短时间窗口,避免关闭同一触摸触发到底层组件
closeTimerRef.current = setTimeout(() => {
onClose();
}, 120);
}, [onClose]);
// 显示短暂提示
const showToast = useCallback((type: 'success' | 'error') => {
setSaveToast(type);
setTimeout(() => setSaveToast(null), 2500);
}, []);
// 保存图片到本地相册
const handleSaveImage = useCallback(async () => {
if (!currentImage || saving || Platform.OS === 'web') return;
const MediaLibrary = require('expo-media-library') as typeof import('expo-media-library');
const { File, Paths } = require('expo-file-system') as typeof import('expo-file-system');
const { status } = await MediaLibrary.requestPermissionsAsync();
if (status !== 'granted') {
Alert.alert('无法保存', '请在系统设置中允许访问相册权限后重试。');
return;
}
setSaving(true);
let downloaded: InstanceType<typeof File> | null = null;
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 = `withyou_${Date.now()}.${fileExt}`;
const destination = new File(Paths.cache, fileName);
// File.downloadFileAsync 是新版 expo-file-system 的静态方法
downloaded = await File.downloadFileAsync(currentImage.url, destination, {
idempotent: true,
});
// expo-media-library v17+ 已移除顶层 saveToLibraryAsync改用 Asset.create()
// 在 Android 上 filePath 必须以 file:/// 开头
await MediaLibrary.Asset.create(downloaded.uri);
onSave?.(currentImage.url);
showToast('success');
} catch (err) {
console.error('[ImageGallery] 保存图片失败:', err);
showToast('error');
} finally {
// 清理缓存文件(无论成功失败都清理,避免残留)
if (downloaded) {
try {
downloaded.delete();
} catch (cleanupErr) {
console.warn('[ImageGallery] 清理缓存文件失败:', cleanupErr);
}
}
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);
useEffect(() => {
pendingSwipeDirectionRef.current = null;
swipeTranslateX.value = 0;
}, [currentIndex, swipeTranslateX]);
const switchWithDirection = useCallback(
(targetIndex: number, direction: -1 | 1) => {
updateIndexFromSwipe(targetIndex, direction);
},
[updateIndexFromSwipe]
);
const goToPrev = useCallback(() => {
if (currentIndex > 0) {
switchWithDirection(currentIndex - 1, 1);
}
}, [currentIndex, switchWithDirection]);
const goToNext = useCallback(() => {
if (currentIndex < validImages.length - 1) {
switchWithDirection(currentIndex + 1, -1);
}
}, [currentIndex, validImages.length, switchWithDirection]);
// 统一的滑动手势:放大时拖动,未放大时切换图片
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;
}
})
.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) {
// 向左滑动,显示下一张
runOnJS(updateIndexFromSwipe)(currentIndex + 1, -1);
} else if (shouldGoPrev && currentIndex > 0) {
// 向右滑动,显示上一张
runOnJS(updateIndexFromSwipe)(currentIndex - 1, 1);
}
}
});
// 点击手势(关闭 gallery
const tapGesture = Gesture.Tap()
.numberOfTaps(1)
.maxDistance(10)
.onEnd(() => {
runOnJS(requestClose)();
});
// 组合手势:
// - 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={requestClose}
statusBarTranslucent
>
<GestureHandlerRootView style={styles.root} data-image-viewer="true">
<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={requestClose}>
<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 ? (
<MaterialCommunityIcons name="loading" size={24} color="#FFF" />
) : (
<MaterialCommunityIcons name="download" size={24} color="#FFF" />
)}
</TouchableOpacity>
)}
</View>
)}
{/* 图片显示区域 */}
<GestureDetector gesture={composedGesture}>
<View style={styles.imageContainer}>
{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.url}
transition={null}
allowDownscaling
onLoad={() => {
setError(false);
}}
onError={() => {
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,
},
errorContainer: {
...StyleSheet.absoluteFill,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#000',
},
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;