- Introduced dynamic chat settings for font size and message bubble radius, improving user customization. - Updated ChatScreen styles to support dynamic themes and responsive layouts. - Refactored bubble styles to utilize dynamic properties for better visual consistency. - Simplified chat settings management by integrating Zustand store for state management. This update significantly enhances the chat interface and user experience by allowing personalized settings and improved visual elements.
660 lines
18 KiB
TypeScript
660 lines
18 KiB
TypeScript
/**
|
||
* 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.5%',
|
||
},
|
||
gridItem3: {
|
||
width: '31.8%',
|
||
},
|
||
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;
|