Merge pull request 'feat/1.0.2' (#3) from feat/1.0.2 into main

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-06-27 20:36:04 +08:00
7 changed files with 349 additions and 13 deletions
+88 -2
View File
@@ -7,6 +7,7 @@
- **四向无限环绕** — 任意方向无限滑动,网格坐标取模映射到数据下标,无缝衔接。
- **视口虚拟化** — 只为「视口 + 一圈缓冲」创建少量节点,节点数与 `items` 总量解耦;平移只改一次容器偏移(O(1)),跨边界时回收/新建少数格子。
- **拖拽 + 惯性** — 拖拽平移,松手按 `friction` 衰减滑动。
- **命令式平滑平移** — 通过 `ref` 调用 `scrollBy`,在指定时长内按贝塞尔曲线(曲速)或预设缓动平移画布,可随时 `stopScroll` 停止。
- **响应式布局** — 布局 props 可传函数,依容器尺寸动态计算;内部 `ResizeObserver` 监听并按签名去重,无变化零开销。
- **可视区域回调** — `onVisibleCardsChange` 实时拿到屏幕内的卡片及其可视度(0~1)。
- **移动 / 悬停回调** — `onMovingChange` 监听拖拽与惯性的起止,`onCardHover` 监听指针进出卡片。
@@ -121,6 +122,49 @@ const byWidth = <V,>(w: number, sm: V, md: V, lg: V): V =>
性能上回调用 `requestAnimationFrame` 合帧、并对结果按可视度量化去重,空闲时零开销;容器尺寸变化也会触发重算(边缘卡片可视度会变)。
## 命令式平滑平移(scrollBy
通过 `ref` 拿到画布句柄,调用 `scrollBy` 让画布内容在指定时长内平滑平移,缓动可传贝塞尔控制点(曲速)或预设名;`stopScroll` 随时停在当前位置。
```tsx
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
@@ -147,6 +191,24 @@ const byWidth = <V,>(w: number, sm: V, md: V, lg: V): V =>
| `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"` | 缓动曲线。 |
### 类型
```ts
@@ -163,6 +225,29 @@ export interface VisibleCard<T> {
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;
}
```
## 工作原理
@@ -173,8 +258,9 @@ export interface VisibleCard<T> {
2. **视口虚拟化** — 只为「视口 + `overscan` 圈缓冲」维护活动节点;平移跨越网格边界时,离开的节点销毁、新进入的用 `renderCard` 新建。节点开销与 `items` 总量彻底解耦,百万级数据也只渲染几十个节点。
3. **取模环绕** — 由 `virtualGrid.indexAt` 将无限延伸的整数网格坐标 `(col, row)` 取模映射到 `items` 下标,环绕不依赖节点是否真实存在。
4. **惯性滑动** — 松手记录速度,每帧按 `friction` 衰减直到停止。
5. **可视扫描**平移或 resize 后用 rAF 合帧,扫描活动节点与视口的重叠面积算出可视度,并对结果量化去重后回调
6. **响应式重建**`ResizeObserver` 监听容器,仅当几何签名(列数/卡片尺寸/间距)变化时才丢弃节点重建;仅视口或 `overscan` 变化时走更便宜的对账分支
5. **程序化平移**`scrollBy` 用独立 rAF 按缓动曲线分帧推进同一套平移逻辑(每帧按进度差量平移,避免累计误差);与惯性各用独立句柄,拖拽按下即打断
6. **可视扫描**平移或 resize 后用 rAF 合帧,扫描活动节点与视口的重叠面积算出可视度,并对结果量化去重后回调
7. **响应式重建**`ResizeObserver` 监听容器,仅当几何签名(列数/卡片尺寸/间距)变化时才丢弃节点重建;仅视口或 `overscan` 变化时走更便宜的对账分支。
## 本地运行
+53 -1
View File
@@ -1,7 +1,10 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Box, Text } from "leafer-ui";
import { InfiniteCanvas } from "./components/InfiniteCanvas";
import type { VisibleCard } from "./components/InfiniteCanvas";
import type {
VisibleCard,
InfiniteCanvasHandle
} from "./components/InfiniteCanvas";
import { lazyItems } from "./lib/utils";
// 入场动画配置(单位:秒)
@@ -41,6 +44,9 @@ const byWidth = <V,>(w: number, sm: V, md: V, lg: V): V =>
w < 768 ? sm : w < 1440 ? md : lg;
function App() {
// 画布命令式句柄(scrollBy / stopScroll
const canvasRef = useRef<InfiniteCanvasHandle>(null);
// 当前可视卡片列表
const [visible, setVisible] = useState<VisibleCard<Card>[]>([]);
@@ -125,6 +131,7 @@ function App() {
return (
<div className="relative w-screen h-screen bg-[#171717]">
<InfiniteCanvas
ref={canvasRef}
items={items}
renderCard={renderCard}
columns={({ width }) => byWidth(width, 3, 5, 7)}
@@ -142,6 +149,51 @@ function App() {
onMovingChange={(moving) => console.log("moving", moving)}
onVisibleCardsChange={handleVisibleChange}
/>
{/* 程序化平滑平移 */}
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 gap-2">
<button
className="rounded bg-white/15 px-3 py-1.5 text-sm text-white backdrop-blur hover:bg-white/25"
onClick={() =>
canvasRef.current?.scrollBy({
y: 500,
duration: 600,
easing: "ease-out"
})
}
>
</button>
<button
className="rounded bg-white/15 px-3 py-1.5 text-sm text-white backdrop-blur hover:bg-white/25"
onClick={() =>
canvasRef.current?.scrollBy({
y: -500,
duration: 600,
easing: "ease-out"
})
}
>
</button>
<button
className="rounded bg-white/15 px-3 py-1.5 text-sm text-white backdrop-blur hover:bg-white/25"
onClick={() =>
canvasRef.current?.scrollBy({
x: -800,
duration: 900,
easing: [0.22, 1, 0.36, 1]
})
}
>
</button>
<button
className="rounded bg-white/15 px-3 py-1.5 text-sm text-white backdrop-blur hover:bg-white/25"
onClick={() => canvasRef.current?.stopScroll()}
>
</button>
</div>
{/* 实时展示可视卡片:可视度 + 左上角坐标 */}
<div className="pointer-events-none absolute right-3 top-3 max-h-[80vh] w-56 overflow-hidden rounded-lg bg-black/60 p-3 text-xs text-white">
<div className="mb-1 font-bold"> ({visible.length})</div>
@@ -1,7 +1,7 @@
import { useRef } from "react";
import type { CSSProperties } from "react";
import { useImperativeHandle, useRef } from "react";
import type { CSSProperties, Ref } from "react";
import { useInfiniteSlide } from "./useInfiniteSlide";
import type { InfiniteCanvasProps } from "./types";
import type { InfiniteCanvasProps, InfiniteCanvasHandle } from "./types";
const fillStyle: CSSProperties = {
width: "100%",
@@ -12,11 +12,27 @@ const fillStyle: CSSProperties = {
/**
* 通用无限滑动画布。卡片通过 renderCard 插槽以 Leafer 节点形式渲染,
* 支持拖拽平移、惯性滑动、点击回调与四向无限环绕
* 支持拖拽平移、惯性滑动、点击回调与四向无限环绕
* 通过 ref 暴露命令式 scrollBy / stopScroll(见 InfiniteCanvasHandle)。
*/
export function InfiniteCanvas<T>(props: InfiniteCanvasProps<T>) {
export function InfiniteCanvas<T>({
ref,
...props
}: InfiniteCanvasProps<T> & { ref?: Ref<InfiniteCanvasHandle> }) {
const containerRef = useRef<HTMLDivElement>(null);
useInfiniteSlide(containerRef, props);
// 引擎在内部把命令式句柄写进 apiRef
const apiRef = useRef<InfiniteCanvasHandle | null>(null);
useInfiniteSlide(containerRef, props, apiRef);
// 转发句柄:调用时读 apiRef.current,保证拿到最新闭包(effect 重建后仍有效)
useImperativeHandle(
ref,
() => ({
scrollBy: (options) => apiRef.current?.scrollBy(options),
stopScroll: () => apiRef.current?.stopScroll()
}),
[]
);
return (
<div
+76
View File
@@ -0,0 +1,76 @@
import type { Easing } from "./types";
/**
* 标准 CSS cubic-bezier 求值:返回 (t)=>y。
* 入参 t 是时间轴归一化进度(即贝塞尔的 x),先由 x 牛顿迭代反解参数 s,
* 失败回退二分,再用 s 求贝塞尔 y。控制点 P0=(0,0)、P3=(1,1) 固定。
*/
export function cubicBezier(
x1: number,
y1: number,
x2: number,
y2: number
): (t: number) => number {
// 多项式系数(B(t)=((a*t+b)*t+c)*t 形式)
const cx = 3 * x1;
const bx = 3 * (x2 - x1) - cx;
const ax = 1 - cx - bx;
const cy = 3 * y1;
const by = 3 * (y2 - y1) - cy;
const ay = 1 - cy - by;
const sampleX = (t: number) => ((ax * t + bx) * t + cx) * t;
const sampleY = (t: number) => ((ay * t + by) * t + cy) * t;
const sampleDx = (t: number) => (3 * ax * t + 2 * bx) * t + cx;
// 由 x 反解贝塞尔参数 t
const solve = (x: number) => {
let t = x;
// 牛顿迭代(最多 8 次)
for (let i = 0; i < 8; i++) {
const xt = sampleX(t) - x;
if (Math.abs(xt) < 1e-6) return t;
const d = sampleDx(t);
if (Math.abs(d) < 1e-6) break;
t -= xt / d;
}
// 回退二分
let lo = 0;
let hi = 1;
t = x;
while (lo < hi) {
const xt = sampleX(t);
if (Math.abs(xt - x) < 1e-6) return t;
if (x > xt) lo = t;
else hi = t;
t = (lo + hi) / 2;
}
return t;
};
return (t: number) => {
if (t <= 0) return 0;
if (t >= 1) return 1;
return sampleY(solve(t));
};
}
/** 预设名 → 控制点 */
const PRESETS: Record<string, [number, number, number, number]> = {
ease: [0.25, 0.1, 0.25, 1],
"ease-in": [0.42, 0, 1, 1],
"ease-out": [0, 0, 0.58, 1],
"ease-in-out": [0.42, 0, 0.58, 1]
};
/** 把 Easing 联合类型归一成一个求值函数,缺省 ease-out */
export function resolveEasing(
easing: Easing | undefined
): (t: number) => number {
if (easing === "linear") return (t) => t;
if (Array.isArray(easing)) {
return cubicBezier(easing[0], easing[1], easing[2], easing[3]);
}
const p = PRESETS[easing ?? "ease-out"] ?? PRESETS["ease-out"];
return cubicBezier(p[0], p[1], p[2], p[3]);
}
+8 -1
View File
@@ -1,2 +1,9 @@
export { InfiniteCanvas } from "./InfiniteCanvas";
export type { InfiniteCanvasProps, Responsive, VisibleCard } from "./types";
export type {
InfiniteCanvasProps,
InfiniteCanvasHandle,
ScrollByOptions,
Easing,
Responsive,
VisibleCard
} from "./types";
+29
View File
@@ -103,3 +103,32 @@ export interface InfiniteCanvasProps<T> {
/** 容器内联样式(默认填满父级 100% × 100%) */
style?: CSSProperties;
}
/** 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 方向位移(px),默认 0;正值=内容右移 */
x?: number;
/** y 方向位移(px),默认 0;正值=内容下移 */
y?: number;
/** 动画时长(ms),默认 0 = 立即跳变 */
duration?: number;
/** 缓动曲线,默认 "ease-out" */
easing?: Easing;
}
/** InfiniteCanvas 命令式句柄(通过 ref 暴露) */
export interface InfiniteCanvasHandle {
/** 让画布内容平滑平移指定像素 */
scrollBy(options: ScrollByOptions): void;
/** 立即停止当前程序化动画(停在当前位置) */
stopScroll(): void;
}
@@ -1,7 +1,14 @@
import { useEffect, useRef } from "react";
import { Leafer, Group, PointerEvent } from "leafer-ui";
import type { IUI } from "leafer-ui";
import type { InfiniteCanvasProps, Responsive, VisibleCard } from "./types";
import type {
InfiniteCanvasProps,
InfiniteCanvasHandle,
ScrollByOptions,
Responsive,
VisibleCard
} from "./types";
import { resolveEasing } from "./easing";
import { indexAt, visibleRange, rangeEqual } from "./virtualGrid";
import type { GridRange } from "./virtualGrid";
@@ -20,7 +27,8 @@ import type { GridRange } from "./virtualGrid";
*/
export function useInfiniteSlide<T>(
containerRef: React.RefObject<HTMLDivElement | null>,
props: InfiniteCanvasProps<T>
props: InfiniteCanvasProps<T>,
apiRef?: React.RefObject<InfiniteCanvasHandle | null>
) {
const {
items,
@@ -445,10 +453,70 @@ export function useInfiniteSlide<T>(
raf = requestAnimationFrame(step);
};
// —— 程序化平移(scrollBy)——
// 程序化动画 rAF 句柄(与惯性 raf 分开,互不干扰)
let animRaf = 0;
/** 停止当前程序化动画(停在当前位置) */
const stopAnim = () => {
if (animRaf) {
cancelAnimationFrame(animRaf);
animRaf = 0;
}
};
/**
* 在 duration(ms) 内按 ease 曲线把内容平移 (dx,dy)。
* 每帧按进度算「应到位移」,与上一帧已推进量作差,增量调 shift,避免累计误差。
*/
const animateBy = (
dx: number,
dy: number,
duration: number,
ease: (t: number) => number
) => {
if (duration <= 0) {
shift(dx, dy);
return;
}
const start = performance.now();
let dxDone = 0;
let dyDone = 0;
setMoving(true);
const step = () => {
const t = Math.min(1, (performance.now() - start) / duration);
const p = ease(t);
shift(dx * p - dxDone, dy * p - dyDone);
dxDone = dx * p;
dyDone = dy * p;
if (t >= 1) {
animRaf = 0;
setMoving(false); // 动画自然结束 → 静止
return;
}
animRaf = requestAnimationFrame(step);
};
animRaf = requestAnimationFrame(step);
};
/** 命令式:平滑平移。先停惯性与旧动画,从当前位置重新开始 */
const scrollBy = (options: ScrollByOptions) => {
const { x = 0, y = 0, duration = 0, easing } = options;
stopInertia();
stopAnim();
if (x === 0 && y === 0) return;
animateBy(x, y, duration, resolveEasing(easing));
};
/** 命令式:停止当前程序化动画 */
const stopScroll = () => stopAnim();
// 把句柄写入外部传入的 apiRef(effect 重建时会刷新闭包)
if (apiRef) apiRef.current = { scrollBy, stopScroll };
// 鼠标按下
const onDown = (e: PointerEvent) => {
if (!draggableRef.current) return;
stopInertia();
stopAnim(); // 用户一按下即打断程序化动画,自然接管
dragging = true;
lastX = e.x;
lastY = e.y;
@@ -501,6 +569,8 @@ export function useInfiniteSlide<T>(
layoutRef.current = undefined;
ro.disconnect();
stopInertia();
stopAnim();
if (apiRef) apiRef.current = null; // 卸载后句柄失效
// 拖拽中销毁画布时,置静止并清掉残留速度避免触发惯性
clearIdle();
// 销毁前先取消可视度 rAF,避免在 leafer.destroy() 后触发回调访问已销毁的节点
@@ -511,7 +581,7 @@ export function useInfiniteSlide<T>(
leafer.off(PointerEvent.UP, onUp);
leafer.destroy(); // 连带销毁 group 及全部子节点
};
}, [containerRef, items, background]);
}, [containerRef, items, background, apiRef]);
// props 中的布局配置变化时(含响应式函数返回值变化),触发重新布局;
// layout 内部按签名去重,无实际变化则零开销