328 lines
9.0 KiB
TypeScript
328 lines
9.0 KiB
TypeScript
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;
|