Files

12 KiB
Raw Permalink Blame History

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 会从当前位置替换上一个动画。
  • 平移期间 onMovingChangeonVisibleCardsChange 等回调照常触发。

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 钩子封装了完整的滑动引擎:

  1. 单容器平移 — 所有卡片放进一个 Group,平移只改一次 group.x/y(O(1),与节点数无关),不再逐节点挪位。
  2. 视口虚拟化 — 只为「视口 + overscan 圈缓冲」维护活动节点;平移跨越网格边界时,离开的节点销毁、新进入的用 renderCard 新建。节点开销与 items 总量彻底解耦,百万级数据也只渲染几十个节点。
  3. 取模环绕 — 由 virtualGrid.indexAt 将无限延伸的整数网格坐标 (col, row) 取模映射到 items 下标,环绕不依赖节点是否真实存在。
  4. 惯性滑动 — 松手记录速度,每帧按 friction 衰减直到停止。
  5. 程序化平移scrollBy 用独立 rAF 按缓动曲线分帧推进同一套平移逻辑(每帧按进度差量平移,避免累计误差);与惯性各用独立句柄,拖拽按下即打断。
  6. 可视扫描 — 平移或 resize 后用 rAF 合帧,扫描活动节点与视口的重叠面积算出可视度,并对结果量化去重后回调。
  7. 响应式重建ResizeObserver 监听容器,仅当几何签名(列数/卡片尺寸/间距)变化时才丢弃节点重建;仅视口或 overscan 变化时走更便宜的对账分支。

本地运行

仓库内含一个示例(src/App.tsx),右上角实时展示可视卡片及其可视度:

pnpm install
pnpm dev      # 开发服务器
pnpm build    # 类型检查 + 打包
pnpm preview  # 预览构建产物

技术栈

React 19 · TypeScript · Vite · Leafer-UI · Tailwind CSS