Initial frontend repository commit.
Include app source and update .gitignore to exclude local release artifacts and signing files. Made-with: Cursor
This commit is contained in:
598
src/components/common/ImageGrid.tsx
Normal file
598
src/components/common/ImageGrid.tsx
Normal file
@@ -0,0 +1,598 @@
|
||||
/**
|
||||
* ImageGrid 图片网格组件
|
||||
* 支持 1-9 张图片的智能布局
|
||||
* 根据图片数量自动选择最佳展示方式
|
||||
*/
|
||||
|
||||
import React, { useMemo, useCallback, useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
Dimensions,
|
||||
ViewStyle,
|
||||
Pressable,
|
||||
Text,
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import { SmartImage, ImageSource } from './SmartImage';
|
||||
import { colors, spacing, borderRadius } from '../../theme';
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算图片网格尺寸
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
const SingleImageItem: React.FC<SingleImageItemProps> = ({
|
||||
image,
|
||||
containerWidth,
|
||||
maxHeight,
|
||||
borderRadiusValue,
|
||||
onPress,
|
||||
}) => {
|
||||
const [aspectRatio, setAspectRatio] = useState<number | null>(null);
|
||||
const uri = image.uri || image.url || '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!uri) return;
|
||||
let cancelled = false;
|
||||
|
||||
// 添加超时处理,防止高分辨率图片加载卡住
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!cancelled && aspectRatio === null) {
|
||||
setAspectRatio(SINGLE_IMAGE_DEFAULT_ASPECT_RATIO);
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
Image.getSize(
|
||||
uri,
|
||||
(w, h) => {
|
||||
if (!cancelled) {
|
||||
clearTimeout(timeoutId);
|
||||
setAspectRatio(w / h);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (!cancelled) {
|
||||
clearTimeout(timeoutId);
|
||||
setAspectRatio(SINGLE_IMAGE_DEFAULT_ASPECT_RATIO);
|
||||
}
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [uri]);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={[styles.singleContainer, { width, height, borderRadius: borderRadiusValue }]}
|
||||
>
|
||||
<SmartImage
|
||||
source={{ uri }}
|
||||
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,
|
||||
}) => {
|
||||
// 通过 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)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染横向双图
|
||||
const renderHorizontal = () => {
|
||||
return (
|
||||
<View style={[styles.horizontalContainer, { gap }]}>
|
||||
{displayImages.map((image, index) => (
|
||||
<Pressable
|
||||
key={image.id || index}
|
||||
onPress={() => handleImagePress(index)}
|
||||
style={[
|
||||
styles.horizontalItem,
|
||||
{
|
||||
flex: 1,
|
||||
aspectRatio: 1,
|
||||
borderRadius: borderRadiusValue,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<SmartImage
|
||||
source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
|
||||
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;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={image.id || index}
|
||||
onPress={() => handleImagePress(index)}
|
||||
style={[
|
||||
styles.gridItem,
|
||||
gridColumns === 3 ? styles.gridItem3 : styles.gridItem2,
|
||||
{
|
||||
borderRadius: borderRadiusValue,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<SmartImage
|
||||
source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
|
||||
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;
|
||||
|
||||
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: image.uri || image.url, width: image.width, height: image.height }}
|
||||
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;
|
||||
Reference in New Issue
Block a user