/** * SmartImage 智能图片组件 * 支持加载状态、错误处理、自适应尺寸 * 基于 expo-image 封装,原生支持 GIF/WebP 动图 */ import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { View, StyleSheet, ViewStyle, ImageStyle, ActivityIndicator, Pressable, StyleProp, Platform, } from 'react-native'; import { Image as ExpoImage } from 'expo-image'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { borderRadius, useAppColors, type AppColors } from '../../theme'; function createSmartImageStyles(colors: AppColors) { return StyleSheet.create({ image: { flex: 1, width: '100%', height: '100%', }, variantImage: { flex: 1, }, overlay: { ...StyleSheet.absoluteFillObject, justifyContent: 'center', alignItems: 'center', backgroundColor: colors.background.disabled, }, loadingContainer: { padding: 8, borderRadius: borderRadius.md, backgroundColor: `${colors.background.paper}E6`, }, errorContainer: { padding: 12, borderRadius: borderRadius.md, backgroundColor: `${colors.text.primary}0D`, }, }); } // 图片加载状态 export type ImageLoadState = 'loading' | 'success' | 'error'; // 图片源类型 - 兼容多种数据格式 export interface ImageSource { uri?: string; url?: string; width?: number; height?: number; } // SmartImage Props export interface SmartImageProps { /** 图片源 */ source: ImageSource | string; /** 容器样式 */ style?: StyleProp; /** 图片样式 */ imageStyle?: StyleProp; /** 图片填充模式 */ resizeMode?: 'cover' | 'contain' | 'stretch' | 'repeat' | 'center'; /** 是否显示加载指示器 */ showLoading?: boolean; /** 是否显示错误占位图 */ showError?: boolean; /** 圆角大小 */ borderRadius?: number; /** 点击回调 */ onPress?: () => void; /** 长按回调 */ onLongPress?: () => void; /** 加载完成回调 */ onLoad?: () => void; /** 加载失败回调 */ onError?: (error: any) => void; /** 测试ID */ testID?: string; /** 预览图 URL */ previewUrl?: string; /** 是否使用预览图(优先加载预览图) */ usePreview?: boolean; /** 是否懒加载 */ lazyLoad?: boolean; /** Web 端 IntersectionObserver rootMargin,略提前加载进入视口附近的图 */ lazyRootMargin?: string; /** 缓存策略 */ cachePolicy?: 'memory' | 'disk' | 'memory-disk' | 'none'; } /** * 智能图片组件 * 自动处理加载状态、错误状态 */ export const SmartImage: React.FC = ({ source, style, imageStyle, resizeMode = 'cover', showLoading = true, showError = true, borderRadius: borderRadiusValue = 0, onPress, onLongPress, onLoad, onError, testID, previewUrl, usePreview = false, lazyLoad = false, lazyRootMargin = '160px', cachePolicy = 'memory-disk', }) => { const colors = useAppColors(); const styles = useMemo(() => createSmartImageStyles(colors), [colors]); const [loadState, setLoadState] = useState('loading'); const [isVisible, setIsVisible] = useState(() => !lazyLoad || Platform.OS !== 'web'); const [shouldLoadOriginal, setShouldLoadOriginal] = useState(false); const lazyTargetRef = useRef(null); // 解析图片源 - 支持 uri 或 url 字段 const imageUri = typeof source === 'string' ? source : (source.uri || source.url || ''); // 解析预览图 URL const previewUri = typeof previewUrl === 'string' ? previewUrl : ''; useEffect(() => { setShouldLoadOriginal(false); setLoadState('loading'); }, [imageUri]); useEffect(() => { if (!lazyLoad) { setIsVisible(true); return; } if (Platform.OS !== 'web') { setIsVisible(true); return; } setIsVisible(false); const node = lazyTargetRef.current as unknown as Element | null; if (!node || typeof IntersectionObserver === 'undefined') { setIsVisible(true); return; } const observer = new IntersectionObserver( entries => { if (entries.some(e => e.isIntersecting)) { setIsVisible(true); observer.disconnect(); } }, { rootMargin: lazyRootMargin } ); observer.observe(node); return () => observer.disconnect(); }, [lazyLoad, lazyRootMargin, imageUri]); // 智能选择图片 URL const getImageUrl = useCallback((): string => { // 如果应该加载原图(预览图已加载完成或没有预览图) if (shouldLoadOriginal || !usePreview || !previewUri) { return imageUri; } // 否则使用预览图 return previewUri; }, [imageUri, previewUri, usePreview, shouldLoadOriginal]); // 处理加载开始 const handleLoadStart = useCallback(() => { setLoadState('loading'); }, []); // 处理加载完成 const handleLoad = useCallback(() => { setLoadState('success'); // 如果正在加载预览图,切换到原图 if (usePreview && !shouldLoadOriginal && previewUri && getImageUrl() === previewUri) { setShouldLoadOriginal(true); } onLoad?.(); }, [onLoad, usePreview, shouldLoadOriginal, previewUri, getImageUrl]); // 处理加载错误 const handleError = useCallback( (error: any) => { // 如果预览图加载失败,尝试加载原图 if (usePreview && !shouldLoadOriginal && getImageUrl() === previewUri && imageUri) { setShouldLoadOriginal(true); return; } setLoadState('error'); onError?.(error); }, [onError, usePreview, shouldLoadOriginal, getImageUrl, previewUri, imageUri] ); // 重试加载 const handleRetry = useCallback(() => { setLoadState('loading'); }, []); // 渲染加载指示器 const renderLoading = () => { if (!showLoading || loadState !== 'loading') return null; return ( ); }; // 渲染错误占位图 const renderError = () => { if (!showError || loadState !== 'error') return null; return ( ); }; // 容器样式 const containerStyle: ViewStyle = { borderRadius: borderRadiusValue, overflow: 'hidden', }; // 图片源配置 const imageSource = imageUri && imageUri.trim() !== '' ? { uri: getImageUrl() } : undefined; // 如果没有有效的图片源,显示错误占位 if (!imageSource) { return ( ); } // 如果是懒加载且不可见,显示占位(Web 上由 IntersectionObserver 驱动进入视口后再加载) if (lazyLoad && !isVisible) { return ( ); } return ( {renderLoading()} {renderError()} ); }; // 预定义尺寸变体 export interface ImageVariantProps extends Omit { /** 尺寸变体 */ variant?: 'thumbnail' | 'small' | 'medium' | 'large' | 'full'; /** 自定义尺寸 */ size?: number; /** 宽高比 */ aspectRatio?: number; } /** * 变体图片组件 * 提供预定义的尺寸变体 */ export const VariantImage: React.FC = ({ variant = 'medium', size, aspectRatio, imageStyle, ...props }) => { const getVariantStyle = (): ViewStyle => { switch (variant) { case 'thumbnail': return { width: size || 40, height: size || 40 }; case 'small': return { width: size || 80, height: size || 80 }; case 'medium': return { width: size || 120, height: size || 120 }; case 'large': return { width: size || 200, height: size || 200 }; case 'full': return { flex: 1 }; default: return { width: size || 120, height: size || 120 }; } }; const variantStyle = getVariantStyle(); // 如果有宽高比,调整高度 const finalStyle: ViewStyle = { ...variantStyle }; if (aspectRatio && finalStyle.width && !size) { finalStyle.height = (finalStyle.width as number) / aspectRatio; } return ( ); }; const variantStyles = StyleSheet.create({ variantImage: { flex: 1, }, }); export default SmartImage;