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

328 lines
9.0 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.

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;