- 新增 SmartImage 组件优化图片加载体验 - 新增 ImageGrid 组件支持多图布局展示 - 新增 imageHelper 工具函数 - 优化 PostCard 帖子卡片图片展示 - 优化消息页面 SegmentRenderer - 优化日程页面 ScheduleScreen - 优化上传服务 uploadService
628 lines
16 KiB
TypeScript
628 lines
16 KiB
TypeScript
/**
|
||
* 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';
|
||
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;
|
||
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;
|
||
}
|
||
|
||
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,
|
||
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;
|
||
|
||
// 获取预览图 URL
|
||
const previewUrl = usePreview && displayMode
|
||
? getPreviewImageUrl(image as any, displayMode)
|
||
: (image.uri || image.url || '');
|
||
|
||
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) => {
|
||
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;
|