12 KiB
12 KiB
InfiniteCanvas 无限滑动画布
基于 Leafer-UI 的 React 无限滑动画布组件。卡片以网格平铺,支持四向无限环绕、拖拽惯性、响应式布局、视口虚拟化,并可监听可视区域内的卡片。
特性
- 四向无限环绕 — 任意方向无限滑动,网格坐标取模映射到数据下标,无缝衔接。
- 视口虚拟化 — 只为「视口 + 一圈缓冲」创建少量节点,节点数与
items总量解耦;平移只改一次容器偏移(O(1)),跨边界时回收/新建少数格子。 - 拖拽 + 惯性 — 拖拽平移,松手按
friction衰减滑动。 - 命令式平滑平移 — 通过
ref调用scrollBy,在指定时长内按贝塞尔曲线(曲速)或预设缓动平移画布,可随时stopScroll停止。 - 响应式布局 — 布局 props 可传函数,依容器尺寸动态计算;内部
ResizeObserver监听并按签名去重,无变化零开销。 - 可视区域回调 —
onVisibleCardsChange实时拿到屏幕内的卡片及其可视度(0~1)。 - 移动 / 悬停回调 —
onMovingChange监听拖拽与惯性的起止,onCardHover监听指针进出卡片。 - 自定义渲染 —
renderCard返回任意 Leafer 节点,完全掌控卡片样式。 - 泛型 + 类型安全 —
InfiniteCanvas<T>与数据类型强绑定。
安装
pnpm add leafer-ui react react-dom
将 src/components/InfiniteCanvas 目录拷贝到你的项目即可使用。
快速开始
import { useCallback } from "react";
import { Box, Text } from "leafer-ui";
import { InfiniteCanvas } from "./components/InfiniteCanvas";
interface Card {
id: number;
color: string;
label: string;
}
const items: Card[] = Array.from({ length: 50 }, (_, i) => ({
id: i + 1,
color: "#54A0FF",
label: `Card ${i + 1}`
}));
function App() {
// 用 useCallback 保持引用稳定,避免每次渲染重建画布
const renderCard = useCallback(
(item: Card, _index: number, size: { width: number; height: number }) =>
new Box({
width: size.width,
height: size.height,
fill: item.color,
cornerRadius: 16,
children: [
new Text({
width: size.width,
height: size.height,
text: item.label,
fill: "#ffffff",
fontSize: Math.round(size.width * 0.11),
textAlign: "center",
verticalAlign: "middle"
})
]
}),
[]
);
return (
<div className="w-screen h-screen">
<InfiniteCanvas
items={items}
renderCard={renderCard}
columns={3}
cardWidth={180}
cardHeight={250}
gapX={32}
gapY={32}
background="#171717"
onCardClick={(item) => console.log("clicked", item)}
/>
</div>
);
}
响应式布局
布局相关 props 支持传入 (size) => value 函数,依容器宽高动态计算。renderCard 的第三个参数 size 即当前解析后的卡片宽高,用它绘制可保证卡片随响应式尺寸缩放。
// 按容器宽度分档:手机窄、平板中、桌面宽
const byWidth = <V,>(w: number, sm: V, md: V, lg: V): V =>
w < 768 ? sm : w < 1440 ? md : lg;
<InfiniteCanvas
items={items}
renderCard={renderCard}
columns={({ width }) => byWidth(width, 3, 5, 7)}
cardWidth={({ width }) => byWidth(width, 180, 260, 340)}
cardHeight={({ width }) => byWidth(width, 250, 360, 470)}
/>;
可视区域回调
onVisibleCardsChange 在可视卡片集合或可视度变化时触发,返回当前屏幕内的卡片列表(按可视度从高到低排序)。可视度 visibility 为 0~1:完全可见为 1。
<InfiniteCanvas
items={items}
renderCard={renderCard}
visibilityThreshold={0.5} // 只关心“露出一半以上”的卡片
onVisibleCardsChange={(visible) => {
for (const v of visible) {
console.log(v.item.label, `${(v.visibility * 100).toFixed(0)}%`);
}
}}
/>
注意:因卡片按网格无限环绕,同一张卡片可能同时出现在屏幕多处,每处是独立实例(key 为该实例的 "col,row" 网格坐标,跨帧稳定),可用 key 作为 React 列表 key。
性能上回调用 requestAnimationFrame 合帧、并对结果按可视度量化去重,空闲时零开销;容器尺寸变化也会触发重算(边缘卡片可视度会变)。
命令式平滑平移(scrollBy)
通过 ref 拿到画布句柄,调用 scrollBy 让画布内容在指定时长内平滑平移,缓动可传贝塞尔控制点(曲速)或预设名;stopScroll 随时停在当前位置。
import { useRef } from "react";
import { InfiniteCanvas } from "./components/InfiniteCanvas";
import type { InfiniteCanvasHandle } from "./components/InfiniteCanvas";
function App() {
const canvasRef = useRef<InfiniteCanvasHandle>(null);
return (
<>
<InfiniteCanvas ref={canvasRef} items={items} renderCard={renderCard} />
{/* 600ms 内向上平移 400px,结尾减速 */}
<button onClick={() => canvasRef.current?.scrollBy({ y: -400, duration: 600, easing: "ease-out" })}>
上移
</button>
{/* 贝塞尔曲速:900ms 内向右平移 800px */}
<button onClick={() => canvasRef.current?.scrollBy({ x: -800, duration: 900, easing: [0.22, 1, 0.36, 1] })}>
曲速右移
</button>
{/* 不传 duration(或 ≤0)则立即跳变,无动画 */}
<button onClick={() => canvasRef.current?.scrollBy({ x: 200 })}>瞬移</button>
{/* 中途停止 */}
<button onClick={() => canvasRef.current?.stopScroll()}>停止</button>
</>
);
}
行为说明:
x > 0内容右移、y > 0内容下移(与浏览器Element.scrollBy方向相反,本组件移动的是内容而非视口)。easing可传 cubic-bezier 控制点[x1, y1, x2, y2](自定义曲速),或预设名"linear" | "ease" | "ease-in" | "ease-out" | "ease-in-out",默认"ease-out"。- 用户一旦拖拽即打断当前程序化动画并接管;重复调用
scrollBy会从当前位置替换上一个动画。 - 平移期间
onMovingChange、onVisibleCardsChange等回调照常触发。
API
InfiniteCanvas<T> Props
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
items |
T[] |
— | 必填。卡片数据数组。 |
renderCard |
(item: T, index: number, size: { width: number; height: number }) => IUI |
— | 必填。卡片插槽,返回一个 Leafer 节点。建议用 useCallback 保持引用稳定。 |
columns |
Responsive<number> |
7 |
列数。 |
cardWidth |
Responsive<number> |
350 |
卡片宽(需与 renderCard 输出一致)。 |
cardHeight |
Responsive<number> |
500 |
卡片高。 |
gapX |
Responsive<number> |
40 |
横向间距。 |
gapY |
Responsive<number> |
40 |
纵向间距。 |
overscan |
number |
1 |
视口外预渲染的缓冲圈数(类似虚拟列表的 overscan)。调大可减少快速滑动时边缘的空白闪现,代价是常驻节点数平方级增长。 |
draggable |
boolean |
true |
是否可拖拽平移。 |
inertia |
boolean |
true |
是否开启惯性滑动。 |
friction |
number |
0.92 |
惯性每帧衰减系数(0~1,越大滑得越久)。 |
onCardClick |
(item: T, index: number) => void |
— | 点击卡片回调。 |
onCardHover |
(item: T, index: number, hovering: boolean) => void |
— | 悬停卡片回调,指针进入 hovering=true、移出 false。 |
onMovingChange |
(moving: boolean) => void |
— | 移动状态回调,拖拽或惯性开始时 true、完全静止后 false(仅状态翻转时各触发一次)。 |
onVisibleCardsChange |
(visible: VisibleCard<T>[]) => void |
— | 可视卡片变化回调。 |
visibilityThreshold |
number |
0 |
可视度阈值,低于此比例的卡片不计入回调。 |
background |
string |
— | 画布背景色。 |
className |
string |
— | 容器 className。 |
style |
CSSProperties |
填满父级 100%×100% | 容器内联样式。 |
命令式句柄 InfiniteCanvasHandle
通过 ref 获取,提供程序化平移能力。
| 方法 | 签名 | 说明 |
|---|---|---|
scrollBy |
(options: ScrollByOptions) => void |
让画布内容平滑平移指定像素。 |
stopScroll |
() => void |
立即停止当前程序化动画(停在当前位置)。 |
ScrollByOptions:
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
x |
number |
0 |
x 方向位移(px),正值=内容右移。 |
y |
number |
0 |
y 方向位移(px),正值=内容下移。 |
duration |
number |
0 |
动画时长(ms),0 或负数=立即跳变。 |
easing |
Easing |
"ease-out" |
缓动曲线。 |
类型
/** 响应式取值:传固定值,或根据容器尺寸动态计算的函数 */
export type Responsive<V> =
| V
| ((size: { width: number; height: number }) => V);
/** 一张当前出现在可视区域内的卡片 */
export interface VisibleCard<T> {
item: T; // 原始卡片数据
index: number; // 在 items 中的下标
visibility: number; // 可视度 0~1(落在视口内的面积占比)
rect: { x: number; y: number; width: number; height: number }; // 相对容器左上角的位置与完整尺寸
key: string; // 本次出现实例的稳定标识(形如 "col,row" 的虚拟网格坐标),可用作 React list key
}
/** cubic-bezier 控制点 [x1,y1,x2,y2],或预设缓动名 */
export type Easing =
| [number, number, number, number]
| "linear"
| "ease"
| "ease-in"
| "ease-out"
| "ease-in-out";
/** scrollBy 选项 */
export interface ScrollByOptions {
x?: number; // x 方向位移(px),正值=内容右移
y?: number; // y 方向位移(px),正值=内容下移
duration?: number; // 动画时长(ms),0 = 立即跳变
easing?: Easing; // 缓动曲线,默认 "ease-out"
}
/** 通过 ref 暴露的命令式句柄 */
export interface InfiniteCanvasHandle {
scrollBy(options: ScrollByOptions): void;
stopScroll(): void;
}
工作原理
useInfiniteSlide 钩子封装了完整的滑动引擎:
- 单容器平移 — 所有卡片放进一个
Group,平移只改一次group.x/y(O(1),与节点数无关),不再逐节点挪位。 - 视口虚拟化 — 只为「视口 +
overscan圈缓冲」维护活动节点;平移跨越网格边界时,离开的节点销毁、新进入的用renderCard新建。节点开销与items总量彻底解耦,百万级数据也只渲染几十个节点。 - 取模环绕 — 由
virtualGrid.indexAt将无限延伸的整数网格坐标(col, row)取模映射到items下标,环绕不依赖节点是否真实存在。 - 惯性滑动 — 松手记录速度,每帧按
friction衰减直到停止。 - 程序化平移 —
scrollBy用独立 rAF 按缓动曲线分帧推进同一套平移逻辑(每帧按进度差量平移,避免累计误差);与惯性各用独立句柄,拖拽按下即打断。 - 可视扫描 — 平移或 resize 后用 rAF 合帧,扫描活动节点与视口的重叠面积算出可视度,并对结果量化去重后回调。
- 响应式重建 —
ResizeObserver监听容器,仅当几何签名(列数/卡片尺寸/间距)变化时才丢弃节点重建;仅视口或overscan变化时走更便宜的对账分支。
本地运行
仓库内含一个示例(src/App.tsx),右上角实时展示可视卡片及其可视度:
pnpm install
pnpm dev # 开发服务器
pnpm build # 类型检查 + 打包
pnpm preview # 预览构建产物
技术栈
React 19 · TypeScript · Vite · Leafer-UI · Tailwind CSS