Files
frontend/src/components/common/ImageGrid.tsx
lafay 4ee3079b9f
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 8m15s
Frontend CI / ota-android (push) Successful in 10m56s
Frontend CI / build-android-apk (push) Successful in 1h3m22s
feat(Theme): enhance theming and UI consistency across components
- 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.
2026-03-25 05:16:54 +08:00

660 lines
18 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.
/**
* ImageGrid 图片网格组件
* 支持 1-9 张图片的智能布局
* 根据图片数量自动选择最佳展示方式
* 支持预览图优化
*/
import React, { useMemo, useCallback, useState, useEffect, createContext, useContext } from 'react';
import {
View,
StyleSheet,
Dimensions,
ViewStyle,
Pressable,
Text,
Image,
} from 'react-native';
import { SmartImage } from './SmartImage';
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } 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,
};
};
function createImageGridStyles(colors: AppColors) {
return 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%',
},
gridItem3: {
width: '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,
},
});
}
type ImageGridStyles = ReturnType<typeof createImageGridStyles>;
const ImageGridStylesContext = createContext<ImageGridStyles | null>(null);
function useImageGridStyles(): ImageGridStyles {
const ctx = useContext(ImageGridStylesContext);
if (!ctx) {
throw new Error('useImageGridStyles must be used within ImageGrid or CompactImageGrid');
}
return ctx;
}
// ─── 单张图片子组件 ───────────────────────────────────────────────────────────
// 独立成组件,方便用 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 styles = useImageGridStyles();
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,
}) => {
const colors = useAppColors();
const gridStyles = useMemo(() => createImageGridStyles(colors), [colors]);
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={[gridStyles.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={[
gridStyles.horizontalItem,
{
flex: 1,
aspectRatio: 1,
borderRadius: borderRadiusValue,
},
]}
>
<SmartImage
source={{ uri: previewUrl }}
style={gridStyles.fullSize}
resizeMode="cover"
borderRadius={borderRadiusValue}
/>
</Pressable>
);
})}
</View>
);
};
// 渲染网格布局
const renderGrid = () => {
return (
<View style={[gridStyles.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={[
gridStyles.gridItem,
gridColumns === 3 ? gridStyles.gridItem3 : gridStyles.gridItem2,
{
borderRadius: borderRadiusValue,
},
]}
>
<SmartImage
source={{ uri: previewUrl }}
style={gridStyles.fullSize}
resizeMode="cover"
borderRadius={borderRadiusValue}
/>
{showOverlay && (
<View style={gridStyles.moreOverlay}>
<Text style={gridStyles.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={[gridStyles.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={[
gridStyles.masonryItem,
{
width: itemWidth,
height: Math.max(height, itemWidth * 0.7),
borderRadius: borderRadiusValue,
},
]}
>
<SmartImage
source={{ uri: previewUrl }}
style={gridStyles.fullSize}
resizeMode="cover"
borderRadius={borderRadiusValue}
/>
</Pressable>
);
})}
</View>
);
};
return (
<View style={[gridStyles.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 (
<ImageGridStylesContext.Provider value={gridStyles}>
<View
style={[gridStyles.container, style]}
testID={testID}
onLayout={e => setContainerWidth(e.nativeEvent.layout.width)}
>
{renderContent()}
</View>
</ImageGridStylesContext.Provider>
);
};
// 紧凑模式 - 用于评论等小空间场景
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 colors = useAppColors();
const gridStyles = useMemo(() => createImageGridStyles(colors), [colors]);
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={[
gridStyles.compactItem,
{
width: size,
height: size,
borderRadius: borderRadiusValue,
},
]}
>
<SmartImage
source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
style={gridStyles.fullSize}
resizeMode="cover"
borderRadius={borderRadiusValue}
/>
</Pressable>
);
}
// 多张图片使用小网格
const columns = count <= 4 ? 2 : 3;
const { itemSize } = calculateGridDimensions(count, containerWidth, gap, columns);
return (
<View style={[gridStyles.compactGrid, { gap }]}>
{images.slice(0, 6).map((image, index) => (
<Pressable
key={image.id || index}
onPress={() => props.onImagePress?.(images, index)}
style={[
gridStyles.compactItem,
{
width: itemSize,
height: itemSize,
borderRadius: borderRadiusValue,
},
]}
>
<SmartImage
source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
style={gridStyles.fullSize}
resizeMode="cover"
borderRadius={borderRadiusValue}
/>
{index === 5 && images.length > 6 && (
<View style={gridStyles.moreOverlay}>
<Text style={gridStyles.moreText}>+{images.length - 6}</Text>
</View>
)}
</Pressable>
))}
</View>
);
};
return (
<ImageGridStylesContext.Provider value={gridStyles}>
<View style={gridStyles.compactContainer}>{renderCompactGrid()}</View>
</ImageGridStylesContext.Provider>
);
};
export default ImageGrid;