Files
rn-app/components/Image.tsx
2025-11-13 16:47:10 +08:00

262 lines
8.3 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.
import React from 'react';
import { Image as RNImage, ImageProps as RNImageProps, View, Text } from 'react-native';
import { getImageSourceWithDefault, isLocalPath, convertLocalPathToUri } from '@/utils/image';
import { getGameImageSizeFromPath } from '@/constants/gameImages';
/**
* 自定义 Image 组件
*
* 功能:
* - 自动处理图片 URL 验证
* - 支持默认图片
* - 支持自适应宽度或高度
* - 支持自动测量图片尺寸(无需预知 aspectRatio
* - 简化图片加载逻辑
*
* 使用方式:
* ```typescript
* // 基础用法
* <Image
* source="https://example.com/image.png"
* defaultSource="https://via.placeholder.com/200"
* style={{ width: 100, height: 100 }}
* />
*
* // 设置高度,宽度自适应(需要提供 aspectRatio
* <Image
* source="https://example.com/image.png"
* style={{ height: 200 }} // 只设置高度
* aspectRatio={16 / 9}
* adaptiveMode="height"
* />
*
* // 设置宽度,高度自适应(需要提供 aspectRatio
* <Image
* source="https://example.com/image.png"
* style={{ width: 300 }} // 只设置宽度
* aspectRatio={4 / 3}
* adaptiveMode="width"
* />
*
* // 自动测量图片尺寸(推荐用于未知尺寸的图片)
* <Image
* source="https://example.com/image.png"
* style={{ height: 50 }}
* adaptiveMode="height"
* autoMeasure={true}
* />
* ```
*/
interface CustomImageProps extends Omit<RNImageProps, 'source' | 'defaultSource'> {
/** 图片 URLHTTP/HTTPS 或 Data URI */
source?: string;
/** 默认图片 URL当主图片无效时使用 */
defaultSource?: string;
/** 是否显示加载失败的占位符 */
showPlaceholder?: boolean;
/** 图片宽高比(用于自适应宽度或高度) */
aspectRatio?: number;
/** 自适应模式:
* - 'height': 高度固定宽度自适应style 中只需设置 height
* - 'width': 宽度固定高度自适应style 中只需设置 width
*/
adaptiveMode?: 'width' | 'height';
/** 是否自动测量图片尺寸(当 aspectRatio 未提供时) */
autoMeasure?: boolean;
}
const Image = React.forwardRef<RNImage, CustomImageProps>(
(
{
source,
defaultSource = 'https://via.placeholder.com/200',
showPlaceholder = false,
style,
aspectRatio,
adaptiveMode,
autoMeasure = false,
onLoad,
...props
},
ref
) => {
// 自动测量的宽高比状态
const [measuredAspectRatio, setMeasuredAspectRatio] = React.useState<number | null>(null);
// 获取有效的图片 URI
const imageUri = getImageSourceWithDefault(source, defaultSource);
// 处理图片加载完成事件
const handleImageLoad = React.useCallback(
(event: any) => {
if (autoMeasure) {
try {
// 尝试从不同的位置获取图片尺寸
let width: number | undefined;
let height: number | undefined;
// 方式 1: event.nativeEvent.source
if (event?.nativeEvent?.source) {
width = event.nativeEvent.source.width;
height = event.nativeEvent.source.height;
}
// 方式 2: event.nativeEvent
if (!width || !height) {
width = event?.nativeEvent?.width;
height = event?.nativeEvent?.height;
}
// 方式 3: 直接从 event
if (!width || !height) {
width = event?.width;
height = event?.height;
}
if (width && height && typeof width === 'number' && typeof height === 'number') {
const ratio = width / height;
setMeasuredAspectRatio(ratio);
if (__DEV__) {
console.log(
`[Image] Measured dimensions from event: ${width}x${height}, ratio: ${ratio.toFixed(3)}`
);
}
} else {
// 如果从 event 获取失败,尝试从本地路径的映射表获取
if (isLocalPath(source)) {
const size = getGameImageSizeFromPath(source);
if (size && size.width && size.height) {
const ratio = size.width / size.height;
setMeasuredAspectRatio(ratio);
if (__DEV__) {
console.log(
`[Image] Measured dimensions from mapping: ${size.width}x${size.height}, ratio: ${ratio.toFixed(3)}`
);
}
} else if (__DEV__) {
console.warn(
`[Image] Failed to get dimensions from event or mapping for: ${source}`
);
}
} else if (__DEV__) {
console.warn(
`[Image] Failed to get dimensions from event. Event:`,
JSON.stringify(event, null, 2)
);
}
}
} catch (error) {
// 忽略获取尺寸的错误
console.warn('Failed to measure image dimensions:', error);
}
}
onLoad?.(event);
},
[autoMeasure, onLoad, source]
);
// 计算自适应样式
const adaptiveStyle = React.useMemo(() => {
// 优先使用提供的 aspectRatio其次使用测量的 aspectRatio
const ratio = aspectRatio || (autoMeasure ? measuredAspectRatio : null);
if (!ratio || !adaptiveMode) {
return {};
}
// 从 style 中提取宽度和高度
const styleObj = Array.isArray(style) ? Object.assign({}, ...style) : style || {};
const fixedWidth = styleObj.width;
const fixedHeight = styleObj.height;
// adaptiveMode 的含义:
// - 'height': 高度固定,宽度自适应
// 计算方式width = height * aspectRatio
// - 'width': 宽度固定,高度自适应
// 计算方式height = width / aspectRatio
if (adaptiveMode === 'height' && fixedHeight) {
// 高度固定,计算宽度
// 注意:不返回 aspectRatio让宽高完全由计算值决定
// 这样可以避免 resizeMode 对宽高比的影响
const calculatedWidth = fixedHeight * ratio;
if (__DEV__) {
console.log(
`[Image] adaptiveMode=height: fixedHeight=${fixedHeight}, ratio=${ratio.toFixed(3)}, calculatedWidth=${calculatedWidth.toFixed(1)}`
);
}
return {
width: calculatedWidth,
height: fixedHeight,
};
} else if (adaptiveMode === 'width' && fixedWidth) {
// 宽度固定,计算高度
// 注意:不返回 aspectRatio让宽高完全由计算值决定
const calculatedHeight = fixedWidth / ratio;
if (__DEV__) {
console.log(
`[Image] adaptiveMode=width: fixedWidth=${fixedWidth}, ratio=${ratio.toFixed(3)}, calculatedHeight=${calculatedHeight.toFixed(1)}`
);
}
return {
width: fixedWidth,
height: calculatedHeight,
};
}
// 如果没有固定值,仅使用 aspectRatio
return { aspectRatio: ratio };
}, [aspectRatio, adaptiveMode, autoMeasure, measuredAspectRatio, style]);
// 如果没有有效的图片 URI显示占位符
if (!imageUri) {
if (showPlaceholder) {
return (
<View
style={[
{
backgroundColor: '#f0f0f0',
justifyContent: 'center',
alignItems: 'center',
},
style,
adaptiveStyle,
]}
>
<Text style={{ color: '#999', fontSize: 12 }}></Text>
</View>
);
}
return null;
}
// 处理本地路径和外部 URL
let imageSource: any;
if (isLocalPath(imageUri)) {
// 本地路径:/images/game/chess/color_101.png
// 转换为 require 对象
const localSource = convertLocalPathToUri(imageUri);
imageSource = localSource || { uri: defaultSource };
} else {
// 外部 URL
imageSource = { uri: imageUri };
}
return (
<RNImage
ref={ref}
source={imageSource}
style={[style]}
onLoad={handleImageLoad}
{...props}
/>
);
}
);
Image.displayName = 'Image';
export default Image;