Files
frontend/src/components/common/ImageGrid.tsx

646 lines
17 KiB
TypeScript
Raw Normal View History

/**
* ImageGrid
* 1-9
*
*
*/
import React, { useMemo, useCallback, useState, useEffect } from 'react';
import {
View,
StyleSheet,
Dimensions,
ViewStyle,
Pressable,
Text,
Image,
} from 'react-native';
import { SmartImage } from './SmartImage';
import { colors, spacing, borderRadius } from '../../theme';
import { getPreviewImageUrl, ImageDisplayMode } from '../../utils/imageHelper';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
// 默认容器内边距(用于计算可用宽度)
const DEFAULT_CONTAINER_PADDING = spacing.lg * 2; // 左右各 spacing.lg
// 单张图片的默认宽高比
const SINGLE_IMAGE_DEFAULT_ASPECT_RATIO = 4 / 3; // 默认4:3比例
// 单张图片的最大高度
const SINGLE_IMAGE_MAX_HEIGHT = 400;
// 单张图片的最小高度
const SINGLE_IMAGE_MIN_HEIGHT = 150;
// 图片项类型 - 兼容 PostImageDTO 和 CommentImage
export interface ImageGridItem {
id?: string;
uri?: string;
url?: string;
thumbnailUrl?: string;
thumbnail_url?: string;
preview_url?: string;
preview_url_large?: string;
width?: number;
height?: number;
}
// 布局模式
export type GridLayoutMode = 'auto' | 'single' | 'horizontal' | 'grid' | 'masonry';
// 组件 Props
export interface ImageGridProps {
/** 图片列表 */
images: ImageGridItem[];
/** 容器样式 */
style?: ViewStyle;
/** 最大显示数量 */
maxDisplayCount?: number;
/** 布局模式 */
mode?: GridLayoutMode;
/** 图片间距 */
gap?: number;
/** 圆角大小 */
borderRadius?: number;
/** 是否显示更多遮罩 */
showMoreOverlay?: boolean;
/** 单张图片最大高度 */
singleImageMaxHeight?: number;
/** 网格列数(2或3) */
gridColumns?: 2 | 3;
/** 图片点击回调 */
onImagePress?: (images: ImageGridItem[], index: number) => void;
/** 测试ID */
testID?: string;
/** 显示模式 */
displayMode?: ImageDisplayMode;
/** 是否使用预览图 */
usePreview?: boolean;
}
/**
*
*/
const calculateGridDimensions = (
count: number,
containerWidth: number,
gap: number,
columns: number
) => {
const totalGap = (columns - 1) * gap;
const itemSize = (containerWidth - totalGap) / columns;
// 计算行数
const rows = Math.ceil(count / columns);
return {
itemSize,
rows,
containerHeight: rows * itemSize + (rows - 1) * gap,
};
};
// ─── 单张图片子组件 ───────────────────────────────────────────────────────────
// 独立成组件,方便用 useState 管理异步加载到的实际尺寸
interface SingleImageItemProps {
image: ImageGridItem;
containerWidth: number;
maxHeight: number;
borderRadiusValue: number;
onPress: () => void;
usePreview: boolean;
displayMode: ImageDisplayMode;
}
const SingleImageItem: React.FC<SingleImageItemProps> = ({
image,
containerWidth,
maxHeight,
borderRadiusValue,
onPress,
usePreview,
displayMode,
}) => {
const [aspectRatio, setAspectRatio] = useState<number | null>(null);
const mainUri = image.uri || image.url || '';
const thumbnailUri =
image.thumbnail_url ||
image.thumbnailUrl ||
image.preview_url ||
'';
useEffect(() => {
if (image.width && image.height) {
setAspectRatio(image.width / image.height);
return;
}
const probeUri = thumbnailUri || mainUri;
if (!probeUri) return;
let cancelled = false;
const timeoutId = setTimeout(() => {
if (!cancelled && aspectRatio === null) {
setAspectRatio(SINGLE_IMAGE_DEFAULT_ASPECT_RATIO);
}
}, 3000);
Image.getSize(
probeUri,
(w, h) => {
if (!cancelled) {
clearTimeout(timeoutId);
setAspectRatio(w / h);
}
},
() => {
if (!cancelled) {
clearTimeout(timeoutId);
setAspectRatio(SINGLE_IMAGE_DEFAULT_ASPECT_RATIO);
}
},
);
return () => {
cancelled = true;
clearTimeout(timeoutId);
};
}, [image.width, image.height, thumbnailUri, mainUri]);
const effectiveContainerWidth = containerWidth || SCREEN_WIDTH - DEFAULT_CONTAINER_PADDING;
// 尺寸还没拿到时先占位,避免闪烁
if (aspectRatio === null) {
return (
<View
style={[
styles.singleContainer,
{
width: effectiveContainerWidth,
height: SINGLE_IMAGE_MIN_HEIGHT,
borderRadius: borderRadiusValue,
},
]}
/>
);
}
// 适配最大边界框,保证宽高比不变
let width: number;
let height: number;
if (aspectRatio > effectiveContainerWidth / maxHeight) {
// 宽图:宽度撑满容器
width = effectiveContainerWidth;
height = effectiveContainerWidth / aspectRatio;
} else {
// 高图:高度触及上限
height = maxHeight;
width = maxHeight * aspectRatio;
}
// 最小高度兜底
if (height < SINGLE_IMAGE_MIN_HEIGHT) {
height = SINGLE_IMAGE_MIN_HEIGHT;
width = Math.min(SINGLE_IMAGE_MIN_HEIGHT * aspectRatio, effectiveContainerWidth);
}
const previewForSmart =
usePreview && displayMode ? getPreviewImageUrl(image as any, displayMode) : '';
const useSmartPreview = !!(usePreview && previewForSmart && mainUri && previewForSmart !== mainUri);
return (
<Pressable
onPress={onPress}
style={[styles.singleContainer, { width, height, borderRadius: borderRadiusValue }]}
>
<SmartImage
source={{ uri: mainUri }}
previewUrl={useSmartPreview ? previewForSmart : undefined}
usePreview={useSmartPreview}
style={styles.fullSize}
resizeMode="cover"
borderRadius={borderRadiusValue}
/>
</Pressable>
);
};
/**
*
*
*
*/
export const ImageGrid: React.FC<ImageGridProps> = ({
images,
style,
maxDisplayCount = 9,
mode = 'auto',
gap = 4,
borderRadius: borderRadiusValue = borderRadius.md,
showMoreOverlay = true,
singleImageMaxHeight = 300,
gridColumns = 3,
onImagePress,
testID,
displayMode = 'list',
usePreview = true,
}) => {
// 通过 onLayout 拿到容器实际宽度
const [containerWidth, setContainerWidth] = useState(0);
// 过滤有效图片 - 支持 uri 或 url 字段
const validImages = useMemo(() => {
const filtered = images.filter(img => img.uri || img.url || typeof img === 'string');
return filtered;
}, [images]);
// 实际显示的图片
const displayImages = useMemo(() => {
return validImages.slice(0, maxDisplayCount);
}, [validImages, maxDisplayCount]);
// 剩余图片数量
const remainingCount = useMemo(() => {
return Math.max(0, validImages.length - maxDisplayCount);
}, [validImages, maxDisplayCount]);
// 处理图片点击
const handleImagePress = useCallback(
(index: number) => {
onImagePress?.(validImages, index);
},
[onImagePress, validImages]
);
// 确定布局模式
const layoutMode = useMemo(() => {
if (mode !== 'auto') return mode;
const count = displayImages.length;
if (count === 1) return 'single';
if (count === 2) return 'horizontal';
return 'grid';
}, [mode, displayImages.length]);
// 渲染单张图片
const renderSingleImage = () => {
const image = displayImages[0];
if (!image) return null;
return (
<SingleImageItem
image={image}
containerWidth={containerWidth}
maxHeight={singleImageMaxHeight}
borderRadiusValue={borderRadiusValue}
onPress={() => handleImagePress(0)}
usePreview={usePreview}
displayMode={displayMode}
/>
);
};
// 渲染横向双图
const renderHorizontal = () => {
return (
<View style={[styles.horizontalContainer, { gap }]}>
{displayImages.map((image, index) => {
const previewUrl = usePreview && displayMode
? getPreviewImageUrl(image as any, displayMode)
: (image.uri || image.url || '');
return (
<Pressable
key={image.id || index}
onPress={() => handleImagePress(index)}
style={[
styles.horizontalItem,
{
flex: 1,
aspectRatio: 1,
borderRadius: borderRadiusValue,
},
]}
>
<SmartImage
source={{ uri: previewUrl }}
style={styles.fullSize}
resizeMode="cover"
borderRadius={borderRadiusValue}
/>
</Pressable>
);
})}
</View>
);
};
// 渲染网格布局
const renderGrid = () => {
return (
<View style={[styles.gridContainer, { gap }]}>
{displayImages.map((image, index) => {
const isLastVisible = index === displayImages.length - 1;
const showOverlay = isLastVisible && remainingCount > 0 && showMoreOverlay;
const previewUrl = usePreview && displayMode
? getPreviewImageUrl(image as any, displayMode)
: (image.uri || image.url || '');
return (
<Pressable
key={image.id || index}
onPress={() => handleImagePress(index)}
style={[
styles.gridItem,
gridColumns === 3 ? styles.gridItem3 : styles.gridItem2,
{
borderRadius: borderRadiusValue,
},
]}
>
<SmartImage
source={{ uri: previewUrl }}
style={styles.fullSize}
resizeMode="cover"
borderRadius={borderRadiusValue}
/>
{showOverlay && (
<View style={styles.moreOverlay}>
<Text style={styles.moreText}>+{remainingCount}</Text>
</View>
)}
</Pressable>
);
})}
</View>
);
};
// 渲染瀑布流布局
const renderMasonry = () => {
const containerWidth = SCREEN_WIDTH - DEFAULT_CONTAINER_PADDING;
const columns = 2;
const itemWidth = (containerWidth - gap) / columns;
// 将图片分配到两列
const leftColumn: ImageGridItem[] = [];
const rightColumn: ImageGridItem[] = [];
displayImages.forEach((image, index) => {
if (index % 2 === 0) {
leftColumn.push(image);
} else {
rightColumn.push(image);
}
});
const renderColumn = (columnImages: ImageGridItem[], columnIndex: number) => {
return (
<View style={[styles.masonryColumn, { gap }]}>
{columnImages.map((image, index) => {
const actualIndex = columnIndex + index * 2;
const aspectRatio = image.width && image.height
? image.width / image.height
: 1;
const height = itemWidth / aspectRatio;
const previewUrl = usePreview && displayMode
? getPreviewImageUrl(image as any, displayMode)
: (image.uri || image.url || '');
return (
<Pressable
key={image.id || actualIndex}
onPress={() => handleImagePress(actualIndex)}
style={[
styles.masonryItem,
{
width: itemWidth,
height: Math.max(height, itemWidth * 0.7),
borderRadius: borderRadiusValue,
},
]}
>
<SmartImage
source={{ uri: previewUrl }}
style={styles.fullSize}
resizeMode="cover"
borderRadius={borderRadiusValue}
/>
</Pressable>
);
})}
</View>
);
};
return (
<View style={[styles.masonryContainer, { gap }]}>
{renderColumn(leftColumn, 0)}
{renderColumn(rightColumn, 1)}
</View>
);
};
// 根据布局模式渲染
const renderContent = () => {
switch (layoutMode) {
case 'single':
return renderSingleImage();
case 'horizontal':
return renderHorizontal();
case 'masonry':
return renderMasonry();
case 'grid':
default:
return renderGrid();
}
};
// 如果没有图片返回null
if (displayImages.length === 0) {
return null;
}
return (
<View
style={[styles.container, style]}
testID={testID}
onLayout={e => setContainerWidth(e.nativeEvent.layout.width)}
>
{renderContent()}
</View>
);
};
// 紧凑模式 - 用于评论等小空间场景
export interface CompactImageGridProps extends Omit<ImageGridProps, 'mode' | 'gridColumns'> {
/** 最大尺寸限制 */
maxWidth?: number;
}
/**
*
*
*/
export const CompactImageGrid: React.FC<CompactImageGridProps> = ({
maxWidth,
gap = 4,
borderRadius: borderRadiusValue = borderRadius.sm,
...props
}) => {
const containerWidth = maxWidth || SCREEN_WIDTH - DEFAULT_CONTAINER_PADDING - 36 - spacing.sm; // 36是头像宽度
const renderCompactGrid = () => {
const { images } = props;
const count = images.length;
if (count === 0) return null;
if (count === 1) {
const image = images[0];
const size = Math.min(containerWidth * 0.6, 150);
return (
<Pressable
onPress={() => props.onImagePress?.(images, 0)}
style={[
styles.compactItem,
{
width: size,
height: size,
borderRadius: borderRadiusValue,
},
]}
>
<SmartImage
source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
style={styles.fullSize}
resizeMode="cover"
borderRadius={borderRadiusValue}
/>
</Pressable>
);
}
// 多张图片使用小网格
const columns = count <= 4 ? 2 : 3;
const { itemSize } = calculateGridDimensions(count, containerWidth, gap, columns);
return (
<View style={[styles.compactGrid, { gap }]}>
{images.slice(0, 6).map((image, index) => (
<Pressable
key={image.id || index}
onPress={() => props.onImagePress?.(images, index)}
style={[
styles.compactItem,
{
width: itemSize,
height: itemSize,
borderRadius: borderRadiusValue,
},
]}
>
<SmartImage
source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
style={styles.fullSize}
resizeMode="cover"
borderRadius={borderRadiusValue}
/>
{index === 5 && images.length > 6 && (
<View style={styles.moreOverlay}>
<Text style={styles.moreText}>+{images.length - 6}</Text>
</View>
)}
</Pressable>
))}
</View>
);
};
return <View style={styles.compactContainer}>{renderCompactGrid()}</View>;
};
const styles = StyleSheet.create({
container: {
marginTop: spacing.sm,
},
fullSize: {
flex: 1,
width: '100%',
height: '100%',
},
// 单图样式
singleContainer: {
overflow: 'hidden',
backgroundColor: colors.background.disabled,
},
// 横向布局样式
horizontalContainer: {
flexDirection: 'row',
},
horizontalItem: {
overflow: 'hidden',
backgroundColor: colors.background.disabled,
},
// 网格布局样式
gridContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
},
gridItem: {
overflow: 'hidden',
backgroundColor: colors.background.disabled,
aspectRatio: 1,
},
gridItem2: {
width: '48%', // 2列布局每列约48%宽度,留有余量避免换行
},
gridItem3: {
width: '31%', // 3列布局每列约31%宽度,留有余量避免换行
},
// 瀑布流样式
masonryContainer: {
flexDirection: 'row',
},
masonryColumn: {
flex: 1,
},
masonryItem: {
overflow: 'hidden',
backgroundColor: colors.background.disabled,
},
// 更多遮罩 - 类似微博的灰色蒙版
moreOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.4)',
justifyContent: 'center',
alignItems: 'center',
borderRadius: borderRadius.md,
},
moreText: {
color: colors.text.inverse,
fontSize: fontSizes.xl,
fontWeight: '500',
},
// 紧凑模式样式
compactContainer: {
marginTop: spacing.xs,
},
compactGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
},
compactItem: {
overflow: 'hidden',
backgroundColor: colors.background.disabled,
},
});
// 导入字体大小
import { fontSizes } from '../../theme';
export default ImageGrid;