feat: 添加 Leafer 动画
This commit is contained in:
@@ -10,6 +10,8 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@leafer-in/animate": "^2.1.8",
|
||||
"@leafer-in/color": "^2.1.8",
|
||||
"@leafer-ui/core": "^2.1.8",
|
||||
"@leafer-ui/draw": "^2.1.8",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
|
||||
Generated
+34
@@ -8,6 +8,12 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@leafer-in/animate':
|
||||
specifier: ^2.1.8
|
||||
version: 2.1.8(@leafer-in/color@2.1.8(@leafer-in/interface@2.1.8(@leafer-ui/interface@2.1.8)(@leafer/interface@2.1.8))(@leafer-ui/draw@2.1.8)(@leafer-ui/interface@2.1.8))(@leafer-in/interface@2.1.8(@leafer-ui/interface@2.1.8)(@leafer/interface@2.1.8))(@leafer-ui/draw@2.1.8)(@leafer-ui/interface@2.1.8)
|
||||
'@leafer-in/color':
|
||||
specifier: ^2.1.8
|
||||
version: 2.1.8(@leafer-in/interface@2.1.8(@leafer-ui/interface@2.1.8)(@leafer/interface@2.1.8))(@leafer-ui/draw@2.1.8)(@leafer-ui/interface@2.1.8)
|
||||
'@leafer-ui/core':
|
||||
specifier: ^2.1.8
|
||||
version: 2.1.8
|
||||
@@ -220,6 +226,21 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@leafer-in/animate@2.1.8':
|
||||
resolution: {integrity: sha512-vxlmuzOet+yV0N6COlv9SnhvbXVbdGwOua2/T2HBKwiQ4HIjdKdVzezlU2ZBwoytM/CsOlGEWxYeW/Ajrg/8hA==}
|
||||
peerDependencies:
|
||||
'@leafer-in/color': ^2.1.8
|
||||
'@leafer-in/interface': ^2.1.8
|
||||
'@leafer-ui/draw': ^2.1.8
|
||||
'@leafer-ui/interface': ^2.1.8
|
||||
|
||||
'@leafer-in/color@2.1.8':
|
||||
resolution: {integrity: sha512-Nf3IsQkBcErEs71zsEBkR0IRQH+2yl2e5AX+o85DGi8DjJyTD0J3mO+QfeoRmKKHfRZWzfapMrpU25yfJFP+zA==}
|
||||
peerDependencies:
|
||||
'@leafer-in/interface': ^2.1.8
|
||||
'@leafer-ui/draw': ^2.1.8
|
||||
'@leafer-ui/interface': ^2.1.8
|
||||
|
||||
'@leafer-in/interface@2.1.8':
|
||||
resolution: {integrity: sha512-+sjo7e+orseQs6+q7D0DDu31J6jMjl6Ei+M0nqznO8ln5kDYazaOLomb2OmG72WGFqhd28kOxoa65eurjS3P3w==}
|
||||
peerDependencies:
|
||||
@@ -1392,6 +1413,19 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@leafer-in/animate@2.1.8(@leafer-in/color@2.1.8(@leafer-in/interface@2.1.8(@leafer-ui/interface@2.1.8)(@leafer/interface@2.1.8))(@leafer-ui/draw@2.1.8)(@leafer-ui/interface@2.1.8))(@leafer-in/interface@2.1.8(@leafer-ui/interface@2.1.8)(@leafer/interface@2.1.8))(@leafer-ui/draw@2.1.8)(@leafer-ui/interface@2.1.8)':
|
||||
dependencies:
|
||||
'@leafer-in/color': 2.1.8(@leafer-in/interface@2.1.8(@leafer-ui/interface@2.1.8)(@leafer/interface@2.1.8))(@leafer-ui/draw@2.1.8)(@leafer-ui/interface@2.1.8)
|
||||
'@leafer-in/interface': 2.1.8(@leafer-ui/interface@2.1.8)(@leafer/interface@2.1.8)
|
||||
'@leafer-ui/draw': 2.1.8
|
||||
'@leafer-ui/interface': 2.1.8
|
||||
|
||||
'@leafer-in/color@2.1.8(@leafer-in/interface@2.1.8(@leafer-ui/interface@2.1.8)(@leafer/interface@2.1.8))(@leafer-ui/draw@2.1.8)(@leafer-ui/interface@2.1.8)':
|
||||
dependencies:
|
||||
'@leafer-in/interface': 2.1.8(@leafer-ui/interface@2.1.8)(@leafer/interface@2.1.8)
|
||||
'@leafer-ui/draw': 2.1.8
|
||||
'@leafer-ui/interface': 2.1.8
|
||||
|
||||
'@leafer-in/interface@2.1.8(@leafer-ui/interface@2.1.8)(@leafer/interface@2.1.8)':
|
||||
dependencies:
|
||||
'@leafer-ui/interface': 2.1.8
|
||||
|
||||
+51
-2
@@ -1,8 +1,16 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Box, Text } from "leafer-ui";
|
||||
import { InfiniteCanvas } from "./components/InfiniteCanvas";
|
||||
import type { VisibleCard } from "./components/InfiniteCanvas";
|
||||
|
||||
// 入场动画配置(单位:秒)。Leafer 的 animate() 时间以「秒」计(内部按 elapsedMs/1000
|
||||
// 累加并与 duration 比较),不是毫秒——传毫秒会把动画排到几百秒后,卡片一直不显示
|
||||
// 时长预算 ≤1s:基础延迟随“离屏幕中心的距离”增大,再加少量随机抖动
|
||||
const ANIMATION_SEC = 0.7; // 总时长预算
|
||||
const FADE_SEC = 0.26; // 单张淡入时长
|
||||
const SPREAD_SEC = 0.44; // 中心→四周的最大延迟(距离主导)
|
||||
const JITTER_SEC = 0.16; // 每张的随机抖动
|
||||
|
||||
interface Card {
|
||||
id: number;
|
||||
color: string;
|
||||
@@ -41,14 +49,28 @@ function App() {
|
||||
setVisible(v);
|
||||
}, []);
|
||||
|
||||
// 入场动画:进入页面后 ANIMATION_SEC 秒内完成;完成前禁用拖拽
|
||||
const [ready, setReady] = useState(false);
|
||||
const entranceRef = useRef(true); // 仅首屏卡片走淡入;之后(如 resize 重建)直接可见
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
entranceRef.current = false;
|
||||
setReady(true);
|
||||
}, ANIMATION_SEC * 1000);
|
||||
return () => clearTimeout(t);
|
||||
}, []);
|
||||
|
||||
// renderCard 用第三参 size 绘制,保证卡片随响应式尺寸缩放
|
||||
const renderCard = useCallback(
|
||||
(item: Card, _index: number, size: { width: number; height: number }) => {
|
||||
return new Box({
|
||||
const entering = entranceRef.current;
|
||||
const node = new Box({
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
fill: item.color,
|
||||
cornerRadius: 16,
|
||||
opacity: entering ? 0 : 1,
|
||||
children: [
|
||||
new Text({
|
||||
width: size.width,
|
||||
@@ -62,6 +84,32 @@ function App() {
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
// 位置由布局在 renderCard 返回后同步赋值,故延后一帧再按“离中心距离”算延迟
|
||||
if (entering) {
|
||||
requestAnimationFrame(() => {
|
||||
if (node.destroyed) return;
|
||||
const cx = window.innerWidth / 2;
|
||||
const cy = window.innerHeight / 2;
|
||||
const nodeCx = (node.x ?? 0) + size.width / 2;
|
||||
const nodeCy = (node.y ?? 0) + size.height / 2;
|
||||
const dist = Math.hypot(nodeCx - cx, nodeCy - cy);
|
||||
const maxDist = Math.hypot(cx, cy) || 1; // 中心到角的距离,用于归一化
|
||||
const t = Math.min(1, dist / maxDist); // 0=中心 1=最远
|
||||
const delay = t * SPREAD_SEC + Math.random() * JITTER_SEC; // 单位:秒
|
||||
// 从节点当前 opacity(0) 补间到 1。duration/delay 均为「秒」
|
||||
node.animate(
|
||||
{ opacity: 1 },
|
||||
{
|
||||
duration: FADE_SEC,
|
||||
delay,
|
||||
easing: "ease-out"
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return node;
|
||||
},
|
||||
[]
|
||||
);
|
||||
@@ -77,6 +125,7 @@ function App() {
|
||||
gapX={32}
|
||||
gapY={32}
|
||||
friction={0.97}
|
||||
draggable={ready}
|
||||
background="#171717"
|
||||
onCardClick={(item) => console.log("clicked", item)}
|
||||
onVisibleCardsChange={handleVisibleChange}
|
||||
|
||||
@@ -61,7 +61,7 @@ export function useInfiniteSlide<T>(
|
||||
const container = containerRef.current;
|
||||
if (!container || items.length === 0) return;
|
||||
|
||||
// —— 创建画布;禁用内置平移/缩放,改用自定义拖拽以实现无限环绕 ——
|
||||
// 创建画布;禁用内置平移/缩放,改用自定义拖拽以实现无限环绕
|
||||
const leafer = new Leafer({
|
||||
view: container,
|
||||
fill: background,
|
||||
@@ -70,7 +70,7 @@ export function useInfiniteSlide<T>(
|
||||
wheel: { disabled: true }
|
||||
});
|
||||
|
||||
// —— 当前网格状态(由 layout 重建,闭包内拖拽/环绕逻辑共享)——
|
||||
// 当前网格状态(由 layout 重建,闭包内拖拽/环绕逻辑共享)
|
||||
let nodes: IUI[] = [];
|
||||
// 与 nodes 下标对齐的元信息:每个节点对应的原始 item 与其在 items 中的下标
|
||||
let meta: { item: T; index: number }[] = [];
|
||||
@@ -82,17 +82,25 @@ export function useInfiniteSlide<T>(
|
||||
let viewH = 0;
|
||||
let lastSig = ""; // 上次布局签名,用于去重避免无谓重建
|
||||
|
||||
/**
|
||||
* 解析响应式配置值:传函数则按当前容器尺寸计算,返回正数则用,否则用默认值
|
||||
* @param v 响应式配置值
|
||||
* @param de 默认值
|
||||
* @param w 容器宽
|
||||
* @param h 容器高
|
||||
* @returns 解析后的数值(正数)
|
||||
*/
|
||||
const resolve = (
|
||||
v: Responsive<number> | undefined,
|
||||
dflt: number,
|
||||
de: number,
|
||||
w: number,
|
||||
h: number
|
||||
): number => {
|
||||
const r = typeof v === "function" ? v({ width: w, height: h }) : v;
|
||||
return typeof r === "number" && r > 0 ? r : dflt;
|
||||
return typeof r === "number" && r > 0 ? r : de;
|
||||
};
|
||||
|
||||
// —— 可视区域扫描(rAF 合帧 + 签名去重,空闲零开销)——
|
||||
// 可视区域扫描(rAF 合帧 + 签名去重,空闲零开销)
|
||||
let visRaf = 0; // 待执行的扫描帧句柄(0 表示无)
|
||||
let lastVisSig = ""; // 上次回调的结果签名,用于去重
|
||||
|
||||
@@ -226,7 +234,7 @@ export function useInfiniteSlide<T>(
|
||||
layout();
|
||||
layoutRef.current = layout;
|
||||
|
||||
// —— 平移 + 取模环绕(连续 wrap,消除阈值处 1px teleport 闪烁)——
|
||||
// 平移 + 取模环绕(连续 wrap,消除阈值处 1px teleport 闪烁)
|
||||
// 把 v 规整到 [-size, span - size) 区间:屏幕任意位置都被覆盖
|
||||
const wrap = (v: number, span: number, size: number) => {
|
||||
const lo = -size;
|
||||
@@ -240,7 +248,7 @@ export function useInfiniteSlide<T>(
|
||||
scheduleVisible();
|
||||
};
|
||||
|
||||
// —— 拖拽状态 ——
|
||||
// 拖拽状态
|
||||
let dragging = false;
|
||||
let lastX = 0;
|
||||
let lastY = 0;
|
||||
@@ -248,7 +256,7 @@ export function useInfiniteSlide<T>(
|
||||
let vy = 0;
|
||||
let raf = 0;
|
||||
|
||||
// — 停止惯性滑动(取消 requestAnimationFrame)——
|
||||
// 停止惯性滑动(取消 requestAnimationFrame)
|
||||
const stopInertia = () => {
|
||||
if (raf) {
|
||||
cancelAnimationFrame(raf);
|
||||
@@ -256,7 +264,7 @@ export function useInfiniteSlide<T>(
|
||||
}
|
||||
};
|
||||
|
||||
// — 惯性滑动(每帧按 friction 衰减,低于阈值停止)——
|
||||
// 惯性滑动(每帧按 friction 衰减,低于阈值停止)
|
||||
const startInertia = () => {
|
||||
const MIN = 0.1;
|
||||
const step = () => {
|
||||
@@ -272,7 +280,7 @@ export function useInfiniteSlide<T>(
|
||||
raf = requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
// — 拖拽事件处理(PointerEvent)——
|
||||
// 拖拽事件处理(PointerEvent)
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (!draggableRef.current) return;
|
||||
stopInertia();
|
||||
@@ -281,6 +289,7 @@ export function useInfiniteSlide<T>(
|
||||
lastY = e.y;
|
||||
vx = 0;
|
||||
vy = 0;
|
||||
container.style.cursor = "grabbing"; // 按下:抓握手势
|
||||
};
|
||||
|
||||
const onMove = (e: PointerEvent) => {
|
||||
@@ -297,6 +306,7 @@ export function useInfiniteSlide<T>(
|
||||
const onUp = () => {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
container.style.cursor = "grab"; // 松开:恢复可抓握手势
|
||||
if (inertiaRef.current && (Math.abs(vx) > 0.5 || Math.abs(vy) > 0.5)) {
|
||||
startInertia();
|
||||
}
|
||||
@@ -306,11 +316,11 @@ export function useInfiniteSlide<T>(
|
||||
leafer.on(PointerEvent.MOVE, onMove);
|
||||
leafer.on(PointerEvent.UP, onUp);
|
||||
|
||||
// —— 容器尺寸变化时重新布局(解析值/平铺规模变了才真重建,由签名去重)——
|
||||
// 容器尺寸变化时重新布局(解析值/平铺规模变了才真重建,由签名去重)
|
||||
const ro = new ResizeObserver(() => layout());
|
||||
ro.observe(container);
|
||||
|
||||
// —— 清理(StrictMode 双调用安全)——
|
||||
// 清理(StrictMode 双调用安全)
|
||||
return () => {
|
||||
layoutRef.current = undefined;
|
||||
ro.disconnect();
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
// Leafer 插件按副作用导入:把 animate()/颜色能力挂到节点上(仅安装不导入不生效)
|
||||
import "@leafer-in/animate";
|
||||
import "@leafer-in/color";
|
||||
import "./index.css";
|
||||
import App from "./App.tsx";
|
||||
|
||||
|
||||
+16
-1
@@ -2,7 +2,22 @@ import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
// Leafer 插件(@leafer-in/*)通过给 @leafer-ui/draw 的 UI 类打补丁来挂载 animate() 等方法。
|
||||
// 若 Vite 把 leafer-ui 与各插件分别预打包,会各自内联一份 @leafer-ui/draw,
|
||||
// 导致插件补丁打在 A 份、而 Box/Text 继承自 B 份 → animate() 不生效、报“请安装插件”。
|
||||
// dedupe + optimizeDeps.include 强制所有 leafer 包共享同一份 @leafer-ui/draw。
|
||||
const leaferPkgs = [
|
||||
"leafer-ui",
|
||||
"@leafer-ui/draw",
|
||||
"@leafer-ui/core",
|
||||
"@leafer/core",
|
||||
"@leafer-in/animate",
|
||||
"@leafer-in/color"
|
||||
];
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()]
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: { dedupe: leaferPkgs },
|
||||
optimizeDeps: { include: leaferPkgs }
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user