- Updated app.json to set userInterfaceStyle to automatic for improved theme adaptability. - Refactored layout components to utilize useAppColors for dynamic theming, ensuring consistent color usage. - Introduced SystemChrome component to manage system UI background color based on theme. - Enhanced TabsLayout, ProfileStackLayout, and other components to leverage new theming structure. - Improved QRCodeScanner, SearchBar, and CommentItem styles to align with the updated theme system. - Consolidated styles in SystemMessageItem and TabBar for better maintainability and visual coherence.
374 lines
9.7 KiB
TypeScript
374 lines
9.7 KiB
TypeScript
/**
|
||
* 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<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;
|
||
/** 预览图 URL */
|
||
previewUrl?: string;
|
||
/** 是否使用预览图(优先加载预览图) */
|
||
usePreview?: boolean;
|
||
/** 是否懒加载 */
|
||
lazyLoad?: boolean;
|
||
/** Web 端 IntersectionObserver rootMargin,略提前加载进入视口附近的图 */
|
||
lazyRootMargin?: string;
|
||
/** 缓存策略 */
|
||
cachePolicy?: 'memory' | 'disk' | 'memory-disk' | 'none';
|
||
}
|
||
|
||
/**
|
||
* 智能图片组件
|
||
* 自动处理加载状态、错误状态
|
||
*/
|
||
export const SmartImage: React.FC<SmartImageProps> = ({
|
||
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<ImageLoadState>('loading');
|
||
const [isVisible, setIsVisible] = useState(() => !lazyLoad || Platform.OS !== 'web');
|
||
const [shouldLoadOriginal, setShouldLoadOriginal] = useState(false);
|
||
const lazyTargetRef = useRef<View>(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 (
|
||
<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',
|
||
};
|
||
|
||
// 图片源配置
|
||
const imageSource = imageUri && imageUri.trim() !== '' ? { uri: getImageUrl() } : undefined;
|
||
|
||
// 如果没有有效的图片源,显示错误占位
|
||
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>
|
||
);
|
||
}
|
||
|
||
// 如果是懒加载且不可见,显示占位(Web 上由 IntersectionObserver 驱动进入视口后再加载)
|
||
if (lazyLoad && !isVisible) {
|
||
return (
|
||
<View
|
||
ref={Platform.OS === 'web' ? lazyTargetRef : undefined}
|
||
collapsable={false}
|
||
style={[containerStyle, style as ViewStyle, { backgroundColor: colors.background.default }]}
|
||
testID={testID}
|
||
/>
|
||
);
|
||
}
|
||
|
||
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
|
||
}
|
||
cachePolicy={cachePolicy}
|
||
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={[variantStyles.variantImage, imageStyle]}
|
||
/>
|
||
);
|
||
};
|
||
|
||
const variantStyles = StyleSheet.create({
|
||
variantImage: {
|
||
flex: 1,
|
||
},
|
||
});
|
||
|
||
export default SmartImage;
|