diff --git a/README.md b/README.md index 693e553..e61a16c 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ - **四向无限环绕** — 任意方向无限滑动,网格坐标取模映射到数据下标,无缝衔接。 - **视口虚拟化** — 只为「视口 + 一圈缓冲」创建少量节点,节点数与 `items` 总量解耦;平移只改一次容器偏移(O(1)),跨边界时回收/新建少数格子。 - **拖拽 + 惯性** — 拖拽平移,松手按 `friction` 衰减滑动。 +- **命令式平滑平移** — 通过 `ref` 调用 `scrollBy`,在指定时长内按贝塞尔曲线(曲速)或预设缓动平移画布,可随时 `stopScroll` 停止。 - **响应式布局** — 布局 props 可传函数,依容器尺寸动态计算;内部 `ResizeObserver` 监听并按签名去重,无变化零开销。 - **可视区域回调** — `onVisibleCardsChange` 实时拿到屏幕内的卡片及其可视度(0~1)。 - **移动 / 悬停回调** — `onMovingChange` 监听拖拽与惯性的起止,`onCardHover` 监听指针进出卡片。 @@ -121,6 +122,49 @@ const byWidth = (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(null); + + return ( + <> + + + {/* 600ms 内向上平移 400px,结尾减速 */} + + + {/* 贝塞尔曲速:900ms 内向右平移 800px */} + + + {/* 不传 duration(或 ≤0)则立即跳变,无动画 */} + + + {/* 中途停止 */} + + + ); +} +``` + +行为说明: + +- `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` Props @@ -147,6 +191,24 @@ const byWidth = (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 { 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 { 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` 变化时走更便宜的对账分支。 ## 本地运行 diff --git a/src/App.tsx b/src/App.tsx index 77217f9..08ab8cb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 = (w: number, sm: V, md: V, lg: V): V => w < 768 ? sm : w < 1440 ? md : lg; function App() { + // 画布命令式句柄(scrollBy / stopScroll) + const canvasRef = useRef(null); + // 当前可视卡片列表 const [visible, setVisible] = useState[]>([]); @@ -125,6 +131,7 @@ function App() { return (
byWidth(width, 3, 5, 7)} @@ -142,6 +149,51 @@ function App() { onMovingChange={(moving) => console.log("moving", moving)} onVisibleCardsChange={handleVisibleChange} /> + {/* 程序化平滑平移 */} +
+ + + + +
{/* 实时展示可视卡片:可视度 + 左上角坐标 */}
可视卡片 ({visible.length})
diff --git a/src/components/InfiniteCanvas/InfiniteCanvas.tsx b/src/components/InfiniteCanvas/InfiniteCanvas.tsx index 7fc021f..e73e1cb 100644 --- a/src/components/InfiniteCanvas/InfiniteCanvas.tsx +++ b/src/components/InfiniteCanvas/InfiniteCanvas.tsx @@ -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(props: InfiniteCanvasProps) { +export function InfiniteCanvas({ + ref, + ...props +}: InfiniteCanvasProps & { ref?: Ref }) { const containerRef = useRef(null); - useInfiniteSlide(containerRef, props); + // 引擎在内部把命令式句柄写进 apiRef + const apiRef = useRef(null); + useInfiniteSlide(containerRef, props, apiRef); + + // 转发句柄:调用时读 apiRef.current,保证拿到最新闭包(effect 重建后仍有效) + useImperativeHandle( + ref, + () => ({ + scrollBy: (options) => apiRef.current?.scrollBy(options), + stopScroll: () => apiRef.current?.stopScroll() + }), + [] + ); return (
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 = { + 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]); +} diff --git a/src/components/InfiniteCanvas/index.ts b/src/components/InfiniteCanvas/index.ts index 9a7890a..e1c91e6 100644 --- a/src/components/InfiniteCanvas/index.ts +++ b/src/components/InfiniteCanvas/index.ts @@ -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"; diff --git a/src/components/InfiniteCanvas/types.ts b/src/components/InfiniteCanvas/types.ts index 999045f..a932322 100644 --- a/src/components/InfiniteCanvas/types.ts +++ b/src/components/InfiniteCanvas/types.ts @@ -103,3 +103,32 @@ export interface InfiniteCanvasProps { /** 容器内联样式(默认填满父级 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; +} diff --git a/src/components/InfiniteCanvas/useInfiniteSlide.ts b/src/components/InfiniteCanvas/useInfiniteSlide.ts index 6774da2..e289dae 100644 --- a/src/components/InfiniteCanvas/useInfiniteSlide.ts +++ b/src/components/InfiniteCanvas/useInfiniteSlide.ts @@ -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( containerRef: React.RefObject, - props: InfiniteCanvasProps + props: InfiniteCanvasProps, + apiRef?: React.RefObject ) { const { items, @@ -445,10 +453,70 @@ export function useInfiniteSlide( 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( layoutRef.current = undefined; ro.disconnect(); stopInertia(); + stopAnim(); + if (apiRef) apiRef.current = null; // 卸载后句柄失效 // 拖拽中销毁画布时,置静止并清掉残留速度避免触发惯性 clearIdle(); // 销毁前先取消可视度 rAF,避免在 leafer.destroy() 后触发回调访问已销毁的节点 @@ -511,7 +581,7 @@ export function useInfiniteSlide( leafer.off(PointerEvent.UP, onUp); leafer.destroy(); // 连带销毁 group 及全部子节点 }; - }, [containerRef, items, background]); + }, [containerRef, items, background, apiRef]); // props 中的布局配置变化时(含响应式函数返回值变化),触发重新布局; // layout 内部按签名去重,无实际变化则零开销