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;
|