feat: 添加可视区域回调
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
本文件为 Claude Code 在本仓库中工作提供指引。请使用中文回复。
|
||||
|
||||
## 项目概述
|
||||
|
||||
基于 [Leafer-UI](https://www.leaferjs.com/) 的 React 无限滑动画布组件(`InfiniteCanvas`)。卡片以网格平铺,支持四向无限环绕、拖拽平移、惯性滑动、响应式布局与点击回调。核心是一个可复用的组件库(`src/components/InfiniteCanvas`),`src/App.tsx` 仅是演示用例
|
||||
|
||||
技术栈:React 19 · TypeScript · Vite · Leafer-UI · Tailwind CSS v4 · pnpm
|
||||
|
||||
Leafer 文档查询:[https://context7.com/leaferjs/ai-docs/llms.txt?tokens=10000](https://context7.com/leaferjs/ai-docs/llms.txt?tokens=10000)
|
||||
|
||||
## 工作约定
|
||||
|
||||
- 每次改动尽量小,大改动需要先与用户确认
|
||||
- 不要影响原有逻辑
|
||||
- 不要过度封装代码
|
||||
- 代码需要加上中文注释
|
||||
- 必要时需要更新README.md文件,该文件主要是说明如何使用,不需要说明太多技术细节
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev # 启动 Vite 开发服务器
|
||||
pnpm build # 先 tsc -b 类型检查,再 vite build 打包
|
||||
pnpm preview # 预览构建产物
|
||||
pnpm lint # ESLint 检查全仓库
|
||||
```
|
||||
|
||||
注意:`pnpm build` 会先执行 `tsc -b`,类型错误会中断构建。没有配置测试框架
|
||||
|
||||
## 架构
|
||||
|
||||
组件分三层,关注点分离清晰:
|
||||
|
||||
- **InfiniteCanvas.tsx** — 薄壳组件。只创建容器 `div`(默认填满父级、`cursor: grab`、`touchAction: none`)并把 `containerRef` 与 props 交给 hook。所有逻辑都不在这里
|
||||
- **useInfiniteSlide.ts** — 全部滑动引擎逻辑所在。这是改动行为时唯一需要重点理解的文件
|
||||
- **types.ts** — `InfiniteCanvasProps<T>` 与 `Responsive<V>` 类型定义。组件是泛型的,与卡片数据类型 `T` 强绑定
|
||||
|
||||
### 滑动引擎的关键设计(useInfiniteSlide)
|
||||
|
||||
理解这些约束后再改动,否则容易引入画布重建或闪烁问题:
|
||||
|
||||
1. **双 effect + ref 模式**:结构性 effect 的依赖只有 `[containerRef, items, renderCard, background]`,这四者变化才会销毁并重建整个 Leafer 画布。其余易变的配置(`onCardClick` / `draggable` / `inertia` / `friction` / 布局配置)都存在 `ref` 中,由一个无依赖的 passive effect 持续同步最新值,避免触发画布重建。**新增 prop 时务必判断它该进依赖数组还是 ref**
|
||||
2. **`renderCard` 必须引用稳定**:它在结构性 effect 依赖里,每次引用变化都会重建画布。调用方必须用 `useCallback` 包裹
|
||||
3. **签名去重**:`layout()` 会把解析后的布局值拼成 `sig` 字符串,与 `lastSig` 比对,无变化时直接 return,零开销。`ResizeObserver` 和布局配置变化都通过 `layoutRef.current?.()` 触发 `layout()`,靠签名去重避免无谓重建
|
||||
4. **无限环绕用取模 `wrap`**:把每个节点坐标规整到 `[-size, span - size)` 区间,使屏幕任意位置都被卡片覆盖。平铺份数(repX/repY)保证总跨度 ≥ 视口 + 一个步距。连续 wrap(而非阈值跳变)消除 1px 闪烁
|
||||
5. **Leafer 内置交互全部禁用**(`move` / `zoom` / `wheel` 均 `disabled`),改用自定义 `PointerEvent.DOWN/MOVE/UP` 实现拖拽与无限环绕
|
||||
6. **惯性滑动**:松手时记录最近帧速度 `vx/vy`,用 `requestAnimationFrame` 每帧按 `frictionRef` 衰减,低于阈值(0.1)停止
|
||||
7. **点击命中用 `PointerEvent.TAP`**:Leafer 自带拖拽阈值,平移后不会误触发点击
|
||||
8. **清理兼容 StrictMode 双调用**:return 的清理函数会断开 ResizeObserver、停止惯性、解绑事件、销毁画布并清空 `layoutRef`
|
||||
|
||||
### 重要不变量
|
||||
|
||||
`cardWidth` / `cardHeight` 同时用于网格定位、无限环绕命中判定**和**传给 `renderCard` 的 `size`。`renderCard` 实际绘制的尺寸必须与传入的 `size` 一致,否则环绕命中与视觉会错位。响应式场景下务必使用第三个参数 `size` 来绘制
|
||||
|
||||
## 响应式
|
||||
|
||||
布局类 props(`columns` / `cardWidth` / `cardHeight` / `gapX` / `gapY`)均为 `Responsive<V>` 类型,即「固定值」或 `(size) => V` 函数。函数依容器宽高动态计算,由 hook 内的 `resolve()` 解析(返回非正数或非 number 时回退默认值)
|
||||
@@ -1,20 +1,18 @@
|
||||
# InfiniteCanvas 无限滑动画布
|
||||
|
||||
基于 [Leafer-UI](https://www.leaferjs.com/) 的 React 无限滑动画布组件。卡片以网格平铺,支持**四向无限环绕**、**拖拽平移**、**惯性滑动**、**响应式布局**与**点击回调**。卡片内容通过 `renderCard` 插槽以 Leafer 节点形式自由绘制。
|
||||
基于 [Leafer-UI](https://www.leaferjs.com/) 的 React 无限滑动画布组件。卡片以网格平铺,支持四向无限环绕、拖拽惯性、响应式布局,并可监听可视区域内的卡片。
|
||||
|
||||
## 特性
|
||||
|
||||
- **四向无限环绕**:上下左右任意方向都能无限滑动,卡片通过取模循环复用,无缝衔接。
|
||||
- **拖拽 + 惯性**:鼠标/触摸拖拽平移,松手后按摩擦系数衰减惯性滑动。
|
||||
- **响应式布局**:`columns` / `cardWidth` / `cardHeight` / `gapX` / `gapY` 均可传函数,依容器尺寸动态计算;内部用 `ResizeObserver` 监听,并按布局签名去重,无变化时零开销重建。
|
||||
- **自定义渲染**:`renderCard` 返回任意 Leafer 节点(`Box` / `Image` / `Text` / `Rect` …),完全掌控卡片样式。
|
||||
- **点击命中**:基于 Leafer 的 `TAP` 事件(自带拖拽阈值),平移后不会误触发点击。
|
||||
- **泛型支持**:`InfiniteCanvas<T>` 与数据类型强绑定,类型安全。
|
||||
- **四向无限环绕** — 任意方向无限滑动,卡片取模循环复用,无缝衔接。
|
||||
- **拖拽 + 惯性** — 拖拽平移,松手按 `friction` 衰减滑动。
|
||||
- **响应式布局** — 布局 props 可传函数,依容器尺寸动态计算;内部 `ResizeObserver` 监听并按签名去重,无变化零开销。
|
||||
- **可视区域回调** — `onVisibleCardsChange` 实时拿到屏幕内的卡片及其可视度(0~1)。
|
||||
- **自定义渲染** — `renderCard` 返回任意 Leafer 节点,完全掌控卡片样式。
|
||||
- **泛型 + 类型安全** — `InfiniteCanvas<T>` 与数据类型强绑定。
|
||||
|
||||
## 安装
|
||||
|
||||
组件依赖 `leafer-ui` 与 `react`:
|
||||
|
||||
```bash
|
||||
pnpm add leafer-ui react react-dom
|
||||
```
|
||||
@@ -43,8 +41,8 @@ const items: Card[] = Array.from({ length: 50 }, (_, i) => ({
|
||||
function App() {
|
||||
// 用 useCallback 保持引用稳定,避免每次渲染重建画布
|
||||
const renderCard = useCallback(
|
||||
(item: Card, _index: number, size: { width: number; height: number }) => {
|
||||
return new Box({
|
||||
(item: Card, _index: number, size: { width: number; height: number }) =>
|
||||
new Box({
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
fill: item.color,
|
||||
@@ -56,13 +54,11 @@ function App() {
|
||||
text: item.label,
|
||||
fill: "#ffffff",
|
||||
fontSize: Math.round(size.width * 0.11),
|
||||
fontWeight: "bold",
|
||||
textAlign: "center",
|
||||
verticalAlign: "middle"
|
||||
})
|
||||
]
|
||||
});
|
||||
},
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
@@ -76,7 +72,6 @@ function App() {
|
||||
cardHeight={250}
|
||||
gapX={32}
|
||||
gapY={32}
|
||||
friction={0.97}
|
||||
background="#171717"
|
||||
onCardClick={(item) => console.log("clicked", item)}
|
||||
/>
|
||||
@@ -87,7 +82,7 @@ function App() {
|
||||
|
||||
## 响应式布局
|
||||
|
||||
布局相关 props 支持传入 `(size) => value` 函数,依容器宽高动态计算。例如按断点切换列数与卡片尺寸:
|
||||
布局相关 props 支持传入 `(size) => value` 函数,依容器宽高动态计算。`renderCard` 的第三个参数 `size` 即当前解析后的卡片宽高,用它绘制可保证卡片随响应式尺寸缩放。
|
||||
|
||||
```tsx
|
||||
// 按容器宽度分档:手机窄、平板中、桌面宽
|
||||
@@ -100,31 +95,52 @@ const byWidth = <V,>(w: number, sm: V, md: V, lg: V): V =>
|
||||
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`。
|
||||
|
||||
```tsx
|
||||
<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)}%`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
> `renderCard` 的第三个参数 `size` 即当前解析后的卡片宽高,绘制时使用它可保证卡片随响应式尺寸缩放,与网格布局一致。
|
||||
注意:因卡片平铺循环复用,同一张卡片可能同时出现在屏幕多处,每处是独立条目(`key` 不同),可用 `key` 作为 React 列表 key。
|
||||
|
||||
性能上回调用 `requestAnimationFrame` 合帧、并对结果按可视度量化去重,空闲时零开销;容器尺寸变化也会触发重算(边缘卡片可视度会变)。
|
||||
|
||||
## 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` | 纵向间距。 |
|
||||
| `draggable` | `boolean` | `true` | 是否可拖拽平移。 |
|
||||
| `inertia` | `boolean` | `true` | 是否开启惯性滑动。 |
|
||||
| `friction` | `number` | `0.92` | 惯性每帧衰减系数(0~1,越大滑得越久)。 |
|
||||
| `onCardClick` | `(item: T, index: number) => void` | — | 点击卡片回调。 |
|
||||
| `background` | `string` | — | 画布背景色。 |
|
||||
| `className` | `string` | — | 容器`className`。 |
|
||||
| `style` | `CSSProperties` | 填满父级 100%×100% | 容器内联样式。 |
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `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` | 纵向间距。 |
|
||||
| `draggable` | `boolean` | `true` | 是否可拖拽平移。 |
|
||||
| `inertia` | `boolean` | `true` | 是否开启惯性滑动。 |
|
||||
| `friction` | `number` | `0.92` | 惯性每帧衰减系数(0~1,越大滑得越久)。 |
|
||||
| `onCardClick` | `(item: T, index: number) => void` | — | 点击卡片回调。 |
|
||||
| `onVisibleCardsChange` | `(visible: VisibleCard<T>[]) => void` | — | 可视卡片变化回调。 |
|
||||
| `visibilityThreshold` | `number` | `0` | 可视度阈值,低于此比例的卡片不计入回调。 |
|
||||
| `background` | `string` | — | 画布背景色。 |
|
||||
| `className` | `string` | — | 容器 `className`。 |
|
||||
| `style` | `CSSProperties` | 填满父级 100%×100% | 容器内联样式。 |
|
||||
|
||||
### 类型
|
||||
|
||||
@@ -133,25 +149,34 @@ const byWidth = <V,>(w: number, sm: V, md: V, lg: V): V =>
|
||||
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: number; // 本次出现实例的稳定标识,可用作 React list key
|
||||
}
|
||||
```
|
||||
|
||||
## 工作原理
|
||||
|
||||
`useInfiniteSlide` 钩子封装了完整的滑动引擎:
|
||||
|
||||
1. **网格平铺**:将 `columns × baseRows` 的基础瓦片平铺到比视口更大的范围,空槽循环复用 `items`,补全为完整矩形,避免出现空洞。
|
||||
2. **取模环绕**:拖拽/惯性时对每个节点坐标做 `wrap` 取模运算,将其规整到 `[-size, span - size)` 区间,使屏幕任意位置都被卡片覆盖;连续 wrap 消除阈值处的 1px 闪烁。
|
||||
3. **惯性滑动**:松手时记录最近速度,用 `requestAnimationFrame` 每帧按 `friction` 衰减,低于阈值时停止。
|
||||
4. **响应式重建**:`ResizeObserver` 监听容器尺寸,解析布局配置;仅当解析值或平铺规模变化时(签名去重)才真正重建网格。
|
||||
5. **生命周期安全**:易变的回调/配置存于 `ref`,避免触发画布重建;清理逻辑兼容 React StrictMode 的双调用。
|
||||
1. **网格平铺** — 将基础瓦片平铺到比视口更大的范围,空槽循环复用 `items`,避免空洞。
|
||||
2. **取模环绕** — 拖拽/惯性时对每个节点坐标做 `wrap` 取模,规整到 `[-size, span)` 区间,使屏幕任意位置都被覆盖。
|
||||
3. **惯性滑动** — 松手记录速度,每帧按 `friction` 衰减直到停止。
|
||||
4. **可视扫描** — 平移或 resize 后用 rAF 合帧扫描节点与视口的重叠面积,算出可视度并去重回调。
|
||||
5. **响应式重建** — `ResizeObserver` 监听容器,仅当布局签名变化时才真正重建网格。
|
||||
|
||||
## 本地运行
|
||||
|
||||
仓库内含一个使用 50 张色块卡片的示例(`src/App.tsx`),无需任何图片资源即可跑通:
|
||||
仓库内含一个使用 50 张色块卡片的示例(`src/App.tsx`),右上角实时展示可视卡片及其可视度:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev # 启动开发服务器
|
||||
pnpm dev # 开发服务器
|
||||
pnpm build # 类型检查 + 打包
|
||||
pnpm preview # 预览构建产物
|
||||
```
|
||||
|
||||
+24
-2
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Box, Text } from "leafer-ui";
|
||||
import { InfiniteCanvas } from "./components/InfiniteCanvas";
|
||||
import type { VisibleCard } from "./components/InfiniteCanvas";
|
||||
|
||||
interface Card {
|
||||
id: number;
|
||||
@@ -33,6 +34,13 @@ const byWidth = <V,>(w: number, sm: V, md: V, lg: V): V =>
|
||||
w < 768 ? sm : w < 1440 ? md : lg;
|
||||
|
||||
function App() {
|
||||
// 当前可视卡片列表
|
||||
const [visible, setVisible] = useState<VisibleCard<Card>[]>([]);
|
||||
|
||||
const handleVisibleChange = useCallback((v: VisibleCard<Card>[]) => {
|
||||
setVisible(v);
|
||||
}, []);
|
||||
|
||||
// renderCard 用第三参 size 绘制,保证卡片随响应式尺寸缩放
|
||||
const renderCard = useCallback(
|
||||
(item: Card, _index: number, size: { width: number; height: number }) => {
|
||||
@@ -59,7 +67,7 @@ function App() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-screen h-screen bg-[#171717]">
|
||||
<div className="relative w-screen h-screen bg-[#171717]">
|
||||
<InfiniteCanvas
|
||||
items={items}
|
||||
renderCard={renderCard}
|
||||
@@ -71,7 +79,21 @@ function App() {
|
||||
friction={0.97}
|
||||
background="#171717"
|
||||
onCardClick={(item) => console.log("clicked", item)}
|
||||
onVisibleCardsChange={handleVisibleChange}
|
||||
/>
|
||||
{/* 实时展示可视卡片:可视度 + 左上角坐标 */}
|
||||
<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>
|
||||
{visible.map((v) => (
|
||||
<div key={v.key} className="flex justify-between gap-2 tabular-nums">
|
||||
<span className="shrink-0">{v.item.label}</span>
|
||||
<span className="text-white/60">
|
||||
({v.rect.x.toFixed(0)}, {v.rect.y.toFixed(0)})
|
||||
</span>
|
||||
<span>{(v.visibility * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { InfiniteCanvas } from "./InfiniteCanvas";
|
||||
export type { InfiniteCanvasProps, Responsive } from "./types";
|
||||
export type { InfiniteCanvasProps, Responsive, VisibleCard } from "./types";
|
||||
|
||||
@@ -6,6 +6,26 @@ export type Responsive<V> =
|
||||
| V
|
||||
| ((size: { width: number; height: number }) => V);
|
||||
|
||||
/** 一张当前出现在可视区域内的卡片 */
|
||||
export interface VisibleCard<T> {
|
||||
/** 原始卡片数据(来自 items) */
|
||||
item: T;
|
||||
/** item 在 items 中的下标 */
|
||||
index: number;
|
||||
/**
|
||||
* 可视度 0~1:卡片落在视口内的面积占整张卡片面积的比例
|
||||
* 完全可见为 1
|
||||
*/
|
||||
visibility: number;
|
||||
/** 卡片相对容器左上角的位置与完整尺寸(含被视口裁掉的部分) */
|
||||
rect: { x: number; y: number; width: number; height: number };
|
||||
/**
|
||||
* 本次出现实例的稳定标识。同一张卡片因无限平铺可能同时出现在多处,
|
||||
* 每处是独立实例、key 不同;可用作 React list 的 key
|
||||
*/
|
||||
key: number;
|
||||
}
|
||||
|
||||
export interface InfiniteCanvasProps<T> {
|
||||
/** 卡片数据数组 */
|
||||
items: T[];
|
||||
@@ -41,6 +61,17 @@ export interface InfiniteCanvasProps<T> {
|
||||
friction?: number;
|
||||
/** 点击卡片回调 */
|
||||
onCardClick?: (item: T, index: number) => void;
|
||||
/**
|
||||
* 可视区域卡片变化回调:每当可视卡片集合或其可视度发生变化时触发
|
||||
* 内部用 requestAnimationFrame 合帧、并对结果做去重,空闲时零开销
|
||||
* 列表按 visibility 从高到低排序
|
||||
*/
|
||||
onVisibleCardsChange?: (visible: VisibleCard<T>[]) => void;
|
||||
/**
|
||||
* 可视度阈值 0~1:低于该比例的卡片不计入回调结果,默认 0(露一点就算)
|
||||
* 调高可只关心“基本露全”的卡片,并进一步降低回调频率
|
||||
*/
|
||||
visibilityThreshold?: number;
|
||||
|
||||
// —— 外观 / 容器 ——
|
||||
/** 画布背景色(可选) */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Leafer, PointerEvent } from "leafer-ui";
|
||||
import type { IUI } from "leafer-ui";
|
||||
import type { InfiniteCanvasProps, Responsive } from "./types";
|
||||
import type { InfiniteCanvasProps, Responsive, VisibleCard } from "./types";
|
||||
|
||||
/**
|
||||
* 无限滑动引擎:负责 Leafer 生命周期、网格布局、拖拽平移、惯性滑动、
|
||||
@@ -26,6 +26,8 @@ export function useInfiniteSlide<T>(
|
||||
// 易变的回调/配置用 ref 存最新值,避免进入 effect 依赖导致画布重建
|
||||
// 在 passive effect 中更新(不能在 render 期间写 ref)
|
||||
const onCardClickRef = useRef(props.onCardClick);
|
||||
const onVisibleCardsChangeRef = useRef(props.onVisibleCardsChange);
|
||||
const visibilityThresholdRef = useRef(props.visibilityThreshold ?? 0);
|
||||
const draggableRef = useRef(draggable);
|
||||
const inertiaRef = useRef(inertia);
|
||||
const frictionRef = useRef(friction);
|
||||
@@ -41,6 +43,8 @@ export function useInfiniteSlide<T>(
|
||||
|
||||
useEffect(() => {
|
||||
onCardClickRef.current = props.onCardClick;
|
||||
onVisibleCardsChangeRef.current = props.onVisibleCardsChange;
|
||||
visibilityThresholdRef.current = props.visibilityThreshold ?? 0;
|
||||
draggableRef.current = draggable;
|
||||
inertiaRef.current = inertia;
|
||||
frictionRef.current = friction;
|
||||
@@ -68,10 +72,14 @@ export function useInfiniteSlide<T>(
|
||||
|
||||
// —— 当前网格状态(由 layout 重建,闭包内拖拽/环绕逻辑共享)——
|
||||
let nodes: IUI[] = [];
|
||||
// 与 nodes 下标对齐的元信息:每个节点对应的原始 item 与其在 items 中的下标
|
||||
let meta: { item: T; index: number }[] = [];
|
||||
let totalSpanX = 0; // 横向环绕周期
|
||||
let totalSpanY = 0; // 纵向环绕周期
|
||||
let curCardWidth = 0; // 解析后的卡片宽(环绕命中用)
|
||||
let curCardHeight = 0;
|
||||
let viewW = 0; // 最近一次布局时的视口宽(可视度计算用)
|
||||
let viewH = 0;
|
||||
let lastSig = ""; // 上次布局签名,用于去重避免无谓重建
|
||||
|
||||
const resolve = (
|
||||
@@ -84,6 +92,69 @@ export function useInfiniteSlide<T>(
|
||||
return typeof r === "number" && r > 0 ? r : dflt;
|
||||
};
|
||||
|
||||
// —— 可视区域扫描(rAF 合帧 + 签名去重,空闲零开销)——
|
||||
let visRaf = 0; // 待执行的扫描帧句柄(0 表示无)
|
||||
let lastVisSig = ""; // 上次回调的结果签名,用于去重
|
||||
|
||||
// 把一次可视度扫描排进下一帧;同帧多次平移只扫一次
|
||||
const scheduleVisible = () => {
|
||||
if (!onVisibleCardsChangeRef.current || visRaf) return;
|
||||
visRaf = requestAnimationFrame(computeVisible);
|
||||
};
|
||||
|
||||
// 扫描所有节点,算出与视口 [0,viewW]×[0,viewH] 的重叠面积比,产出可视卡片列表
|
||||
const computeVisible = () => {
|
||||
visRaf = 0;
|
||||
const cb = onVisibleCardsChangeRef.current;
|
||||
if (!cb) return;
|
||||
|
||||
const cardArea = curCardWidth * curCardHeight;
|
||||
if (cardArea <= 0) return;
|
||||
const threshold = visibilityThresholdRef.current;
|
||||
|
||||
const result: VisibleCard<T>[] = [];
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
const x = node.x ?? 0;
|
||||
const y = node.y ?? 0;
|
||||
// AABB 早退:与视口无交集直接跳过
|
||||
if (
|
||||
x >= viewW ||
|
||||
y >= viewH ||
|
||||
x + curCardWidth <= 0 ||
|
||||
y + curCardHeight <= 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const overlapW = Math.min(x + curCardWidth, viewW) - Math.max(x, 0);
|
||||
const overlapH = Math.min(y + curCardHeight, viewH) - Math.max(y, 0);
|
||||
const visibility = (overlapW * overlapH) / cardArea;
|
||||
if (visibility < threshold || visibility <= 0) continue;
|
||||
|
||||
const m = meta[i];
|
||||
result.push({
|
||||
item: m.item,
|
||||
index: m.index,
|
||||
visibility,
|
||||
rect: { x, y, width: curCardWidth, height: curCardHeight },
|
||||
key: i
|
||||
});
|
||||
}
|
||||
|
||||
// 最可见的排前面
|
||||
result.sort((a, b) => b.visibility - a.visibility);
|
||||
|
||||
// 结果签名去重:可视度量化到 1% 网格,避免亚像素抖动反复触发回调
|
||||
let sig = "";
|
||||
for (const r of result) {
|
||||
sig += `${r.key}:${Math.round(r.visibility * 100)};`;
|
||||
}
|
||||
if (sig === lastVisSig) return;
|
||||
lastVisSig = sig;
|
||||
|
||||
cb(result);
|
||||
};
|
||||
|
||||
/**
|
||||
* 按当前容器尺寸解析配置并(必要时)重建网格:把 columns×baseRows 的基础
|
||||
* 瓦片平铺到比视口更大,并补全为完整矩形(空槽循环复用 items,避免空洞)
|
||||
@@ -109,13 +180,21 @@ export function useInfiniteSlide<T>(
|
||||
const totalCols = columns * repX;
|
||||
const totalRows = baseRows * repY;
|
||||
|
||||
// 解析值 + 平铺规模都没变 → 跳过,避免无谓重建(如 resize 但未越界)
|
||||
// 视口尺寸即使在网格无需重建时也可能变化(影响边缘卡可视度),故每次都更新
|
||||
viewW = W;
|
||||
viewH = H;
|
||||
|
||||
// 解析值 + 平铺规模都没变 → 跳过重建,但仍重算可视度(视口可能已变)
|
||||
const sig = `${columns}|${cardWidth}|${cardHeight}|${gapX}|${gapY}|${totalCols}|${totalRows}`;
|
||||
if (sig === lastSig) return;
|
||||
if (sig === lastSig) {
|
||||
scheduleVisible();
|
||||
return;
|
||||
}
|
||||
lastSig = sig;
|
||||
|
||||
for (const node of nodes) node.destroy();
|
||||
nodes = [];
|
||||
meta = [];
|
||||
curCardWidth = cardWidth;
|
||||
curCardHeight = cardHeight;
|
||||
totalSpanX = totalCols * stepX;
|
||||
@@ -137,8 +216,11 @@ export function useInfiniteSlide<T>(
|
||||
);
|
||||
leafer.add(node);
|
||||
nodes.push(node);
|
||||
meta.push({ item, index });
|
||||
}
|
||||
}
|
||||
|
||||
scheduleVisible();
|
||||
};
|
||||
|
||||
layout();
|
||||
@@ -155,6 +237,7 @@ export function useInfiniteSlide<T>(
|
||||
node.x = wrap((node.x ?? 0) + dx, totalSpanX, curCardWidth);
|
||||
node.y = wrap((node.y ?? 0) + dy, totalSpanY, curCardHeight);
|
||||
}
|
||||
scheduleVisible();
|
||||
};
|
||||
|
||||
// —— 拖拽状态 ——
|
||||
@@ -232,6 +315,7 @@ export function useInfiniteSlide<T>(
|
||||
layoutRef.current = undefined;
|
||||
ro.disconnect();
|
||||
stopInertia();
|
||||
if (visRaf) cancelAnimationFrame(visRaf);
|
||||
leafer.off(PointerEvent.DOWN, onDown);
|
||||
leafer.off(PointerEvent.MOVE, onMove);
|
||||
leafer.off(PointerEvent.UP, onUp);
|
||||
|
||||
Reference in New Issue
Block a user