2026-03-09 21:29:03 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* SmartImage 智能图片组件
|
|
|
|
|
|
* 支持加载状态、错误处理、自适应尺寸
|
|
|
|
|
|
* 基于 expo-image 封装,原生支持 GIF/WebP 动图
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2026-03-15 02:25:55 +08:00
|
|
|
|
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
2026-03-09 21:29:03 +08:00
|
|
|
|
import {
|
|
|
|
|
|
View,
|
|
|
|
|
|
StyleSheet,
|
|
|
|
|
|
ViewStyle,
|
|
|
|
|
|
ImageStyle,
|
|
|
|
|
|
ActivityIndicator,
|
|
|
|
|
|
Pressable,
|
|
|
|
|
|
StyleProp,
|
2026-03-23 14:21:22 +08:00
|
|
|
|
Platform,
|
2026-03-09 21:29:03 +08:00
|
|
|
|
} from 'react-native';
|
|
|
|
|
|
import { Image as ExpoImage } from 'expo-image';
|
|
|
|
|
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|
|
|
|
|
import { colors, borderRadius } from '../../theme';
|
|
|
|
|
|
|
|
|
|
|
|
// 图片加载状态
|
|
|
|
|
|
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<ViewStyle>;
|
|
|
|
|
|
/** 图片样式 */
|
|
|
|
|
|
imageStyle?: StyleProp<ImageStyle>;
|
|
|
|
|
|
/** 图片填充模式 */
|
|
|
|
|
|
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;
|
2026-03-15 02:25:55 +08:00
|
|
|
|
/** 预览图 URL */
|
|
|
|
|
|
previewUrl?: string;
|
|
|
|
|
|
/** 是否使用预览图(优先加载预览图) */
|
|
|
|
|
|
usePreview?: boolean;
|
|
|
|
|
|
/** 是否懒加载 */
|
|
|
|
|
|
lazyLoad?: boolean;
|
2026-03-23 14:21:22 +08:00
|
|
|
|
/** Web 端 IntersectionObserver rootMargin,略提前加载进入视口附近的图 */
|
|
|
|
|
|
lazyRootMargin?: string;
|
2026-03-15 02:25:55 +08:00
|
|
|
|
/** 缓存策略 */
|
|
|
|
|
|
cachePolicy?: 'memory' | 'disk' | 'memory-disk' | 'none';
|
2026-03-09 21:29:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 智能图片组件
|
|
|
|
|
|
* 自动处理加载状态、错误状态
|
|
|
|
|
|
*/
|
|
|
|
|
|
export const SmartImage: React.FC<SmartImageProps> = ({
|
|
|
|
|
|
source,
|
|
|
|
|
|
style,
|
|
|
|
|
|
imageStyle,
|
|
|
|
|
|
resizeMode = 'cover',
|
|
|
|
|
|
showLoading = true,
|
|
|
|
|
|
showError = true,
|
|
|
|
|
|
borderRadius: borderRadiusValue = 0,
|
|
|
|
|
|
onPress,
|
|
|
|
|
|
onLongPress,
|
|
|
|
|
|
onLoad,
|
|
|
|
|
|
onError,
|
|
|
|
|
|
testID,
|
2026-03-15 02:25:55 +08:00
|
|
|
|
previewUrl,
|
|
|
|
|
|
usePreview = false,
|
|
|
|
|
|
lazyLoad = false,
|
2026-03-23 14:21:22 +08:00
|
|
|
|
lazyRootMargin = '160px',
|
2026-03-15 02:25:55 +08:00
|
|
|
|
cachePolicy = 'memory-disk',
|
2026-03-09 21:29:03 +08:00
|
|
|
|
}) => {
|
|
|
|
|
|
const [loadState, setLoadState] = useState<ImageLoadState>('loading');
|
2026-03-23 14:21:22 +08:00
|
|
|
|
const [isVisible, setIsVisible] = useState(() => !lazyLoad || Platform.OS !== 'web');
|
2026-03-15 02:25:55 +08:00
|
|
|
|
const [shouldLoadOriginal, setShouldLoadOriginal] = useState(false);
|
2026-03-23 14:21:22 +08:00
|
|
|
|
const lazyTargetRef = useRef<View>(null);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
// 解析图片源 - 支持 uri 或 url 字段
|
2026-03-15 02:25:55 +08:00
|
|
|
|
const imageUri = typeof source === 'string'
|
|
|
|
|
|
? source
|
2026-03-09 21:29:03 +08:00
|
|
|
|
: (source.uri || source.url || '');
|
|
|
|
|
|
|
2026-03-15 02:25:55 +08:00
|
|
|
|
// 解析预览图 URL
|
|
|
|
|
|
const previewUri = typeof previewUrl === 'string' ? previewUrl : '';
|
|
|
|
|
|
|
2026-03-23 14:21:22 +08:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-03-15 02:25:55 +08:00
|
|
|
|
// 智能选择图片 URL
|
|
|
|
|
|
const getImageUrl = useCallback((): string => {
|
|
|
|
|
|
// 如果应该加载原图(预览图已加载完成或没有预览图)
|
|
|
|
|
|
if (shouldLoadOriginal || !usePreview || !previewUri) {
|
|
|
|
|
|
return imageUri;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 否则使用预览图
|
|
|
|
|
|
return previewUri;
|
|
|
|
|
|
}, [imageUri, previewUri, usePreview, shouldLoadOriginal]);
|
|
|
|
|
|
|
2026-03-09 21:29:03 +08:00
|
|
|
|
// 处理加载开始
|
|
|
|
|
|
const handleLoadStart = useCallback(() => {
|
|
|
|
|
|
setLoadState('loading');
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
// 处理加载完成
|
|
|
|
|
|
const handleLoad = useCallback(() => {
|
|
|
|
|
|
setLoadState('success');
|
2026-03-15 02:25:55 +08:00
|
|
|
|
// 如果正在加载预览图,切换到原图
|
|
|
|
|
|
if (usePreview && !shouldLoadOriginal && previewUri && getImageUrl() === previewUri) {
|
|
|
|
|
|
setShouldLoadOriginal(true);
|
|
|
|
|
|
}
|
2026-03-09 21:29:03 +08:00
|
|
|
|
onLoad?.();
|
2026-03-15 02:25:55 +08:00
|
|
|
|
}, [onLoad, usePreview, shouldLoadOriginal, previewUri, getImageUrl]);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
// 处理加载错误
|
|
|
|
|
|
const handleError = useCallback(
|
|
|
|
|
|
(error: any) => {
|
2026-03-15 02:25:55 +08:00
|
|
|
|
// 如果预览图加载失败,尝试加载原图
|
|
|
|
|
|
if (usePreview && !shouldLoadOriginal && getImageUrl() === previewUri && imageUri) {
|
|
|
|
|
|
setShouldLoadOriginal(true);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-09 21:29:03 +08:00
|
|
|
|
setLoadState('error');
|
|
|
|
|
|
onError?.(error);
|
|
|
|
|
|
},
|
2026-03-15 02:25:55 +08:00
|
|
|
|
[onError, usePreview, shouldLoadOriginal, getImageUrl, previewUri, imageUri]
|
2026-03-09 21:29:03 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// 重试加载
|
|
|
|
|
|
const handleRetry = useCallback(() => {
|
|
|
|
|
|
setLoadState('loading');
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染加载指示器
|
|
|
|
|
|
const renderLoading = () => {
|
|
|
|
|
|
if (!showLoading || loadState !== 'loading') return null;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<View style={styles.overlay}>
|
|
|
|
|
|
<View style={styles.loadingContainer}>
|
|
|
|
|
|
<ActivityIndicator size="small" color={colors.primary.main} />
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染错误占位图
|
|
|
|
|
|
const renderError = () => {
|
|
|
|
|
|
if (!showError || loadState !== 'error') return null;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Pressable style={styles.overlay} onPress={handleRetry}>
|
|
|
|
|
|
<View style={styles.errorContainer}>
|
|
|
|
|
|
<MaterialCommunityIcons
|
|
|
|
|
|
name="image-off-outline"
|
|
|
|
|
|
size={24}
|
|
|
|
|
|
color={colors.text.hint}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</Pressable>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 容器样式
|
|
|
|
|
|
const containerStyle: ViewStyle = {
|
|
|
|
|
|
borderRadius: borderRadiusValue,
|
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 图片源配置
|
2026-03-15 02:25:55 +08:00
|
|
|
|
const imageSource = imageUri && imageUri.trim() !== '' ? { uri: getImageUrl() } : undefined;
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
// 如果没有有效的图片源,显示错误占位
|
|
|
|
|
|
if (!imageSource) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Pressable
|
|
|
|
|
|
style={[containerStyle, style as ViewStyle, { backgroundColor: colors.background.disabled }]}
|
|
|
|
|
|
testID={testID}
|
|
|
|
|
|
>
|
|
|
|
|
|
<View style={styles.errorContainer}>
|
|
|
|
|
|
<MaterialCommunityIcons
|
|
|
|
|
|
name="image-off-outline"
|
|
|
|
|
|
size={24}
|
|
|
|
|
|
color={colors.text.hint}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</Pressable>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-23 14:21:22 +08:00
|
|
|
|
// 如果是懒加载且不可见,显示占位(Web 上由 IntersectionObserver 驱动进入视口后再加载)
|
2026-03-15 02:25:55 +08:00
|
|
|
|
if (lazyLoad && !isVisible) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<View
|
2026-03-23 14:21:22 +08:00
|
|
|
|
ref={Platform.OS === 'web' ? lazyTargetRef : undefined}
|
|
|
|
|
|
collapsable={false}
|
2026-03-15 02:25:55 +08:00
|
|
|
|
style={[containerStyle, style as ViewStyle, { backgroundColor: '#F5F5F5' }]}
|
|
|
|
|
|
testID={testID}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 21:29:03 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<Pressable
|
|
|
|
|
|
style={[containerStyle, style as ViewStyle]}
|
|
|
|
|
|
onPress={onPress}
|
|
|
|
|
|
onLongPress={onLongPress}
|
|
|
|
|
|
disabled={!onPress && !onLongPress}
|
|
|
|
|
|
testID={testID}
|
|
|
|
|
|
>
|
|
|
|
|
|
<ExpoImage
|
|
|
|
|
|
source={imageSource}
|
|
|
|
|
|
style={[styles.image, imageStyle as ImageStyle]}
|
|
|
|
|
|
contentFit={
|
|
|
|
|
|
resizeMode === 'stretch' ? 'fill' :
|
|
|
|
|
|
resizeMode === 'repeat' ? 'cover' :
|
|
|
|
|
|
resizeMode === 'center' ? 'scale-down' :
|
|
|
|
|
|
resizeMode
|
|
|
|
|
|
}
|
2026-03-15 02:25:55 +08:00
|
|
|
|
cachePolicy={cachePolicy}
|
2026-03-09 21:29:03 +08:00
|
|
|
|
onLoadStart={handleLoadStart}
|
|
|
|
|
|
onLoad={handleLoad}
|
|
|
|
|
|
onError={handleError}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{renderLoading()}
|
|
|
|
|
|
{renderError()}
|
|
|
|
|
|
</Pressable>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 预定义尺寸变体
|
|
|
|
|
|
export interface ImageVariantProps extends Omit<SmartImageProps, 'style'> {
|
|
|
|
|
|
/** 尺寸变体 */
|
|
|
|
|
|
variant?: 'thumbnail' | 'small' | 'medium' | 'large' | 'full';
|
|
|
|
|
|
/** 自定义尺寸 */
|
|
|
|
|
|
size?: number;
|
|
|
|
|
|
/** 宽高比 */
|
|
|
|
|
|
aspectRatio?: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 变体图片组件
|
|
|
|
|
|
* 提供预定义的尺寸变体
|
|
|
|
|
|
*/
|
|
|
|
|
|
export const VariantImage: React.FC<ImageVariantProps> = ({
|
|
|
|
|
|
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 (
|
|
|
|
|
|
<SmartImage
|
|
|
|
|
|
{...props}
|
|
|
|
|
|
style={finalStyle}
|
|
|
|
|
|
imageStyle={[styles.variantImage, imageStyle]}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const styles = 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: 'rgba(255, 255, 255, 0.9)',
|
|
|
|
|
|
},
|
|
|
|
|
|
errorContainer: {
|
|
|
|
|
|
padding: 12,
|
|
|
|
|
|
borderRadius: borderRadius.md,
|
|
|
|
|
|
backgroundColor: 'rgba(0, 0, 0, 0.05)',
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
export default SmartImage;
|