visual-novel-web/src/components/ui/VirtualGrid.tsx

328 lines
9.0 KiB
TypeScript
Raw Normal View History

2025-10-28 07:59:26 +00:00
import type React from 'react';
import { useEffect, useRef, useState } from 'react';
import { useDebounceFn, useUpdate } from 'ahooks';
import cloneDeep from 'lodash/cloneDeep';
import { is } from 'zod/locales';
type VirtualGridProps<T extends any = any> = {
rowHeight?: number;
itemRender?: (item: T) => React.ReactNode;
dataSource?: T[];
noMoreData?: boolean;
noMoreDataRender?: (() => React.ReactNode) | null;
isLoadingMore?: boolean;
isLoading?: boolean;
loadMore?: () => void;
gap?: number;
padding?: {
right?: number;
left?: number;
};
columnCalc?: (width: number) => number;
keySetter?: (item: T) => string;
preRenderHeight?: number;
header?: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>;
function VirtualGrid<T extends any = any>(
props: VirtualGridProps<T>
): React.ReactNode {
const {
rowHeight = 0,
itemRender,
columnCalc,
gap = 10,
dataSource = [],
noMoreData = false,
loadMore,
header,
keySetter,
preRenderHeight = 100,
padding,
isLoadingMore,
isLoading,
noMoreDataRender = () => (
<div
className="absolute flex w-full items-center justify-center"
style={{ top: listHeight.current, height: loadingHeight }}
>
No more data
</div>
),
...others
} = props;
const { left = 0, right = 0 } = padding || {};
const containerRef = useRef<HTMLDivElement | null>(null);
const headerRef = useRef<HTMLDivElement | null>(null);
const listHeight = useRef<number>(0);
const previousDataSourceLength = useRef<number>(0);
// 画布数据
const scrollState = useRef<{
// 画布测量宽高
viewWidth: number;
viewHeight: number;
// 视图的开始位置距离顶部的位置
start: number;
// 列数
column: number;
// 每个cell的实际宽度
width: number;
// header 的测量高度
headerHeight: number;
// 实际可用宽度
usableWidth: number;
}>({
viewWidth: 0,
viewHeight: 0,
start: 0,
column: 0,
width: 0,
headerHeight: 0,
usableWidth: 0,
});
// 计算过布局几何属性的数据
const queueState = useRef<
{
// 源数据
item: T;
// 到顶部的距离
y: number;
// 最终样式
style: React.CSSProperties;
}[]
>([]);
// 需要实际渲染的数据
const renderList = useRef<
{
item: T;
y: number;
style: React.CSSProperties;
}[]
>([]);
// 手动更新,避免多次刷新组件
const update = useUpdate();
// 初始化画布参数在container size改变时需要调用
const initScrollState = () => {
if (!containerRef.current) return;
scrollState.current.viewWidth = containerRef.current.clientWidth;
scrollState.current.viewHeight = containerRef.current.clientHeight;
// 实际可用宽度为视口宽度减去左右padding
scrollState.current.usableWidth =
containerRef.current.clientWidth - left - right;
// 根据实际可用宽度计算列数
const column = columnCalc?.(scrollState.current.usableWidth) || 1;
scrollState.current.column = column;
scrollState.current.start = containerRef.current.scrollTop;
// 每个cell的实际宽度(为可用宽度除以列数)
scrollState.current.width =
(scrollState.current.usableWidth - (column - 1) * gap!) / column;
// header 的测量高度
scrollState.current.headerHeight = headerRef.current?.clientHeight || 0;
};
const genereateRenderList = () => {
const finalStart = scrollState.current.start - preRenderHeight!;
const finalEnd =
scrollState.current.start +
scrollState.current.viewHeight +
preRenderHeight!;
const newRenderList = [];
// 注意这里需要遍历dataSource的长度而不是queueState的长度
for (let i = 0; i < dataSource.length; i++) {
if (
queueState.current[i].y + rowHeight! >= finalStart &&
queueState.current[i].y <= finalEnd
) {
newRenderList.push(cloneDeep(queueState.current[i]));
}
}
if (
newRenderList.length !== renderList.current.length ||
newRenderList[0]?.y !== renderList.current[0]?.y ||
newRenderList[0]?.style.width !== renderList.current[0]?.style.width
) {
update();
}
renderList.current = newRenderList;
};
// 重新计算高度和滚动位置
const resetHeightAndScroll = () => {
// 最小有一行
const maxRow = Math.max(
Math.ceil(dataSource.length / scrollState.current.column),
1
);
// 高度 = 最大行数 * 行高 + (最大行数 - 1) * 间距 + header 高度
listHeight.current =
maxRow * rowHeight! +
(maxRow - 1) * gap! +
scrollState.current.headerHeight;
// 如果数据长度小于等于之前的,则滚动到顶部
if (dataSource.length <= previousDataSourceLength.current) {
containerRef.current?.scrollTo({
top: 0,
behavior: 'instant',
});
}
previousDataSourceLength.current = dataSource.length;
genereateRenderList();
};
const calculateCellRect = (i: number) => {
// 第几行, 从0开始
const row = Math.floor(i / scrollState.current.column);
// 第几列, 从0开始
const col = i % scrollState.current.column;
// 到顶部的距离
const y = scrollState.current.headerHeight + row * rowHeight! + gap! * row;
// 到左边的距离
const x = left + col * scrollState.current.width + col * gap!;
return {
y,
x,
};
};
// 页面尺寸变化,重新计算布局
const resizeQueueData = () => {
for (let i = 0; i < dataSource.length; i++) {
const { y, x } = calculateCellRect(i);
queueState.current[i].style = {
height: rowHeight!,
width: scrollState.current.width,
transform: `translate(${x}px, ${y}px)`,
};
queueState.current[i].y = y;
}
resetHeightAndScroll();
};
const updateQueueData = () => {
for (let i = 0; i < dataSource.length; i++) {
const item = dataSource[i];
// 如果是已经计算过,则只更新数据
if (queueState.current[i]) {
queueState.current[i].item = dataSource[i];
continue;
}
const { y, x } = calculateCellRect(i);
queueState.current.push({
item: item,
y: y,
style: {
height: rowHeight!,
width: scrollState.current.width,
transform: `translate(${x}px, ${y}px)`,
},
});
}
resetHeightAndScroll();
};
const handleScroll = () => {
if (!containerRef.current) return;
scrollState.current.start = containerRef.current.scrollTop;
genereateRenderList();
if (noMoreData || isLoadingMore || isLoading) return;
const { scrollTop, clientHeight } = containerRef.current;
if (scrollTop + clientHeight >= listHeight.current) {
loadMore?.();
}
};
const { run: handleResize } = useDebounceFn(
() => {
initScrollState();
resizeQueueData();
},
{
wait: 200,
maxWait: 300,
}
);
// 监听容器和header的尺寸变化
useEffect(() => {
const resizeObserver = new ResizeObserver(() => {
handleResize();
});
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
resizeObserver.observe(headerRef.current!);
}
return () => {
if (containerRef.current) {
resizeObserver.unobserve(containerRef.current);
resizeObserver.unobserve(headerRef.current!);
}
resizeObserver.disconnect();
};
}, []);
// 当 dataSource 变化时,重新计算布局
useEffect(() => {
// 初始化布局
if (!scrollState.current.viewWidth) {
initScrollState();
}
updateQueueData();
}, [dataSource]);
const loadingHeight = 20;
return (
<div
ref={containerRef}
{...others}
className={`overflow-auto ${others.className}`}
onScroll={handleScroll}
>
<div
className="relative w-full"
style={{ height: listHeight.current + loadingHeight }}
>
<div ref={headerRef} className="flex">
{header}
</div>
{renderList.current.map(({ item, style }) => (
<div
className="absolute top-0 left-0 overflow-hidden"
key={keySetter?.(item)}
style={style}
>
{itemRender?.(item)}
</div>
))}
{!!renderList.current.length && !noMoreData && (
<div
className="absolute flex w-full items-center justify-center"
style={{ top: listHeight.current, height: loadingHeight }}
>
Loading more...
</div>
)}
{noMoreData && noMoreDataRender?.()}
{isLoadingMore && (
<div className="absolute flex w-full items-center justify-center">
Loading...
</div>
)}
{!renderList.current.length && !isLoading && (
<div className="absolute flex w-full items-center justify-center">
No data
</div>
)}
</div>
</div>
);
}
export default VirtualGrid;