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

338 lines
9.3 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.

'use client';
import type React from 'react';
import { useEffect, useRef } from 'react';
import { useDebounceFn, useUpdate } from 'ahooks';
type VirtualGridProps<T extends any = any> = {
rowHeight?: number;
itemRender?: (item: T) => React.ReactNode;
dataSource?: T[];
noMoreData?: boolean;
noMoreDataRender?: (() => React.ReactNode) | null;
isLoadingMore?: boolean;
isFirstLoading?: boolean;
loadMore?: () => void;
gap?: number;
padding?: {
right?: number;
left?: number;
};
columnCalc?: (width: number) => number;
keySetter?: (item: T) => string | number;
preRenderHeight?: number;
header?: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>;
const useResizeObserver = (
ref: React.RefObject<HTMLDivElement | null>,
callback: () => void
) => {
useEffect(() => {
const resizeObserver = new ResizeObserver(() => {
callback();
});
if (ref.current) {
resizeObserver.observe(ref.current);
}
return () => {
if (ref.current) {
resizeObserver.unobserve(ref.current);
}
resizeObserver.disconnect();
};
}, []);
};
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,
isFirstLoading,
noMoreDataRender = () => 'No more data',
...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
) {
// 注意这里需要浅拷贝, 不然resize的时候会直接更新到renderList, 导致下面计算条件update不生效
newRenderList.push({ ...queueState.current[i] });
}
}
if (
newRenderList.length !== renderList.current.length ||
newRenderList[0]?.y !== renderList.current[0]?.y
) {
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 = item;
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 || isFirstLoading) return;
const { scrollTop, clientHeight } = containerRef.current;
if (scrollTop + clientHeight >= listHeight.current) {
loadMore?.();
}
};
const { run: handleResize } = useDebounceFn(
() => {
initScrollState();
resizeQueueData();
},
{
wait: 200,
maxWait: 300,
}
);
// 监听容器和header的尺寸变化
useResizeObserver(containerRef, handleResize);
useResizeObserver(headerRef, handleResize);
// 当 dataSource 变化时,重新计算布局
useEffect(() => {
// 初始化布局
if (!scrollState.current.viewWidth) {
initScrollState();
}
// 添加到队列
updateQueueData();
}, [dataSource]);
// 插槽高度,用于 loading 和 no more data
const loadingHeight = 20;
return (
<div
ref={containerRef}
{...others}
className={`relative overflow-auto ${others.className}`}
style={{
scrollbarGutter: 'stable', // 为滚动条预留空间,防止布局抖动
...others.style,
}}
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 && !isFirstLoading && (
<div
className="absolute flex w-full items-center justify-center"
style={{ top: listHeight.current, height: loadingHeight }}
>
{isLoadingMore ? 'Loading more...' : 'load more'}
</div>
)}
{/* 没有更多数据 */}
{noMoreData && noMoreDataRender ? (
<div
className="absolute flex w-full items-center justify-center"
style={{ top: listHeight.current, height: loadingHeight }}
>
{noMoreDataRender()}
</div>
) : null}
{/* 没有数据 */}
{!renderList.current.length && !isFirstLoading && (
<div className="absolute flex w-full items-center justify-center">
No data
</div>
)}
</div>
</div>
);
}
export default VirtualGrid;