master
LiuJiaNan 2026-02-25 10:40:21 +08:00
parent ef3bd3509e
commit 3010ec84f3
7 changed files with 404 additions and 211 deletions

View File

@ -1,60 +1,75 @@
/**
* BottomUtils 子项动画 Hook
* BottomUtils 子项动画 Hook分公司工具栏
*
* 功能说明
* 1. 子项列表提供从下向上进入从上向下离开的动画效果
* 2. 子项依次出现的波浪式动画stagger 效果
* 3. 离开时反向依次消失
* 1. 底部工具栏子项列表提供从下向上进入从上向下离开的动画效果
* 2. 实现子项依次出现的波浪式动画stagger 效果
* 3. 离开时反向依次消失营造流畅的视觉体验
*
* 动画效果
* - 进入动画每个子项从下方 50px 处滑入同时淡入y: 50 0, opacity: 0 1
* - 离开动画每个子项向下方 50px 处滑出同时淡出y: 0 50, opacity: 1 0
* 动画参数配置
* - 容器进入动画y: 200 0从下向上滑入opacity: 0 1淡入
* 时长0.3s缓动easeOut先快后慢stagger 间隔0.05s正向
* - 容器离开动画y: 0 200从上向下滑出opacity: 1 0淡出
* 时长0.2s缓动easeIn先慢后快stagger 间隔0.03s反向更快
* - 子项进入动画y: 50 0opacity: 0 1时长0.3s
* - 子项离开动画y: 0 50opacity: 1 0跟随容器 stagger
*
* Stagger 效果
* Stagger 波浪效果说明
* - 进入时visible子项从第一个到最后一个依次出现每个间隔 0.05
* - 离开时hidden子项从最后一个到第一个依次消失每个间隔 0.03 更快
* - 离开时hidden子项从最后一个到第一个依次消失每个间隔 0.03 更快感觉更敏捷
*
* 使用方法
* 1. 在父组件中使用 AnimatePresence 包裹子项容器
* 2. 在子项容器上设置 initial="hidden"animate="visible"exit="hidden"
* 3. 在子项容器上使用 containerVariants
* 4. 在每个子项上使用 itemVariants
* 3. 在子项容器上使用 variants={containerVariants}
* 4. 在每个子项上使用 variants={itemVariants}
*
* @returns {object} 返回包含以下属性的对象
* - containerVariants: 容器的动画变体用于 motion.div
* - itemVariants: 子项的动画变体用于每个子项
* - containerVariants: 容器的动画变体用于 motion.div variants 属性
* - itemVariants: 子项的动画变体用于每个子项 motion.div variants 属性
*/
export function useBranchOfficeUtilsAnimation() {
// ==================== 容器动画变体 ====================
// 作用:控制整个子项容器的动画和子项的 stagger 效果
//
// 容器动画说明:
// - 控制整个子项容器的进入和离开动画
// - 通过 staggerChildren 实现子项的波浪式出现/消失效果
// - staggerDirection 控制子项的出现顺序1=正向,-1=反向)
// ====================
const containerVariants = {
// 隐藏状态
// - 用途 1作为初始状态initial="hidden"
// - 用途 2作为离开状态exit="hidden"
// - 当同时定义 hidden.transition 和 visible.transition 时
// ----- 隐藏状态 -----
// 用途:
// 1. 作为初始状态initial="hidden"
// 2. 作为离开状态exit="hidden"
// 注意:当同时定义 hidden.transition 和 visible.transition 时
// Framer Motion 会根据当前是进入还是离开自动选择对应的 transition
hidden: {
opacity: 0, // 容器整体透明
y: 200, // 容器从下方 200px 处开始/离开到下方 200px
// 离开时的 transition 配置(当 exit="hidden" 时使用)
// 进入时会使用 visible.transition所以这里只配置离开相关的参数
transition: {
duration: 0.2, // 容器离开动画时长0.2 秒(比进入快)
ease: "easeIn", // 容器离开缓动函数
duration: 0.2, // 容器离开动画时长0.2 秒(比进入快,显得更敏捷)
ease: "easeIn", // 容器离开缓动函数:先慢后快,快速消失
// 子项依次离开的反向波浪效果
staggerChildren: 0.03, // 离开时间隔更短0.03 秒),显得更轻快
staggerDirection: -1, // -1 = 反向(从最后一个到第一个)
},
},
// 可见状态(进入动画)
// - 当组件挂载后立即使用animate="visible"
// ----- 可见状态(进入动画)-----
// 用途:当组件挂载后立即使用animate="visible"
visible: {
opacity: 1, // 容器整体不透明
y: 0, // 容器移动到正常位置
transition: {
duration: 0.3, // 容器动画时长0.3 秒
ease: "easeOut", // 容器缓动函数
ease: "easeOut", // 容器缓动函数:先快后慢,营造舒适的进入体验
// 子项依次出现的波浪效果(进入时使用)
staggerChildren: 0.05, // 每个子项间隔 0.05 秒依次出现
staggerDirection: 1, // 1 = 正向(从第一个到最后一个)
@ -63,30 +78,37 @@ export function useBranchOfficeUtilsAnimation() {
};
// ==================== 子项动画变体 ====================
// 作用:控制单个子项的进入和离开动画
//
// 子项动画说明:
// - 控制单个子项的进入和离开动画
// - 子项动画由容器的 staggerChildren 控制执行时机
// - 每个子项独立执行相同的动画,但时间上有间隔
// ====================
const itemVariants = {
// 隐藏状态
// - 作为初始状态initial="hidden"
// - 作为离开的目标状态exit="hidden" 时子项会动画到此状态)
// ----- 隐藏状态 -----
// 用途:
// 1. 作为初始状态initial="hidden"
// 2. 作为离开的目标状态exit="hidden" 时子项会动画到此状态)
hidden: {
y: 50, // Y 轴位置:从下方 50px 处开始/离开到下方 50px
opacity: 0, // 透明度:完全透明
},
// 可见状态
// - 作为进入的目标状态animate="visible" 时子项会动画到此状态)
// ----- 可见状态 -----
// 用途:作为进入的目标状态animate="visible" 时子项会动画到此状态)
visible: {
y: 0, // Y 轴位置:移动到正常位置
opacity: 1, // 透明度:变为完全不透明
transition: {
duration: 0.3, // 动画时长0.3 秒
ease: "easeOut", // 缓动函数:快速开始,缓慢结束
ease: "easeOut", // 缓动函数:快速开始,缓慢结束,更自然
},
},
};
// ==================== 返回值 ====================
// 返回容器和子项的动画变体
return {
containerVariants, // 容器的动画变体,用于 motion.div 的 variants 属性
itemVariants, // 子项的动画变体,用于每个子项 motion.div 的 variants 属性

View File

@ -46,6 +46,7 @@ function Content(props) {
isVisible: isLeftVisible,
isCollapsed: isLeftCollapsed,
handleCollapse: handleLeftCollapse,
menuControls: leftMenuControls,
} = useContentAnimation({
currentContent: { currentPort, currentBranchOffice, bottomUtilsCurrentIndex },
side: "left",
@ -59,6 +60,7 @@ function Content(props) {
isVisible: isRightVisible,
isCollapsed: isRightCollapsed,
handleCollapse: handleRightCollapse,
menuControls: rightMenuControls,
} = useContentAnimation({
currentContent: { currentBranchOffice, bottomUtilsCurrentIndex },
side: "right",
@ -159,13 +161,25 @@ function Content(props) {
if (currentPort === "00003" && !currentBranchOffice) {
return (
<div
className={`collapse_menu port left ${isLeftCollapsed ? "active" : ""}`}
<motion.div
className="collapse_menu port left"
style={{ backgroundImage: `url(${collapseMenuBg1})` }}
onClick={() => handleLeftCollapse()}
animate={leftMenuControls}
onClick={handleLeftCollapse}
initial={{ x: 0 }}
>
<img src={collapseMenuImg1} alt="" />
</div>
<motion.img
src={collapseMenuImg1}
alt=""
animate={{
x: "-50%",
y: "-50%",
rotate: isLeftCollapsed ? 180 : 0,
}}
transition={{ duration: isLeftCollapsed ? 0.3 : 0.5, ease: isLeftCollapsed ? "easeIn" : "easeOut" }}
style={{ left: "50%", top: "50%", position: "absolute" }}
/>
</motion.div>
);
}
@ -175,22 +189,46 @@ function Content(props) {
return (
<>
{bottomUtilsCurrentType !== "camera" && (
<div
className={`collapse_menu branch_office left ${isLeftCollapsed ? "active" : ""}`}
<motion.div
className="collapse_menu branch_office left"
style={{ backgroundImage: `url(${collapseMenuBg2})` }}
onClick={() => handleLeftCollapse()}
animate={leftMenuControls}
onClick={handleLeftCollapse}
initial={{ x: 0 }}
>
<img src={collapseMenuImg2} alt="" />
</div>
<motion.img
src={collapseMenuImg2}
alt=""
animate={{
x: "-50%",
y: "-50%",
rotate: isLeftCollapsed ? 0 : 180,
}}
transition={{ duration: isLeftCollapsed ? 0.3 : 0.5, ease: isLeftCollapsed ? "easeIn" : "easeOut" }}
style={{ left: "50%", top: "50%", position: "absolute" }}
/>
</motion.div>
)}
{bottomUtilsCurrentIndex === -1 && (
<div
className={`collapse_menu branch_office right ${isRightCollapsed ? "active" : ""}`}
<motion.div
className="collapse_menu branch_office right"
style={{ backgroundImage: `url(${collapseMenuBg2})` }}
onClick={() => handleRightCollapse()}
animate={rightMenuControls}
onClick={handleRightCollapse}
initial={{ x: 0, rotate: 180 }}
>
<img src={collapseMenuImg2} alt="" />
</div>
<motion.img
src={collapseMenuImg2}
alt=""
animate={{
x: "-50%",
y: "-50%",
rotate: isRightCollapsed ? 0 : 180,
}}
transition={{ duration: isRightCollapsed ? 0.3 : 0.5, ease: isRightCollapsed ? "easeIn" : "easeOut" }}
style={{ left: "50%", top: "50%", position: "absolute" }}
/>
</motion.div>
)}
</>
);

View File

@ -56,21 +56,6 @@
&.left {
left: 465px;
&.active {
left: 0;
img {
transform: translate(-50%, -50%) rotate(180deg);
}
}
img {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%) rotate(0);
}
&.port {
width: 30px;
height: 89px;
@ -95,25 +80,9 @@
&.right {
right: 465px;
&.active {
right: 0;
img {
transform: translate(-50%, -50%) rotate(180deg);
}
}
img {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%) rotate(0);
}
&.port {
width: 30px;
height: 89px;
transform: rotate(180deg);
img {
width: 10px;
@ -124,7 +93,6 @@
&.branch_office {
width: 33px;
height: 116px;
transform: rotate(180deg);
img {
width: 13px;

View File

@ -28,10 +28,22 @@ export function useContentAnimation({
type = "panel",
}) {
// ==================== 状态管理 ====================
//
// 动画时长说明:
// - 进入动画0.5s,较慢,营造舒适的进入体验
// - 离开动画0.3s,较快,让用户感觉到响应迅速
//
// 缓动函数说明:
// - easeOut进入时使用先快后慢更自然
// - easeIn离开时使用先慢后快快速消失
// ====================
// motion 动画控制器,用于控制元素的动画
// motion 动画控制器,用于控制内容面板的动画
const controls = useAnimation();
// 菜单按钮的动画控制器,用于控制折叠按钮的动画
const menuControls = useAnimation();
// 元素可见性状态
// false: 元素被隐藏(折叠或纯地图模式)
// true: 元素可见(非折叠、非纯地图模式)
@ -44,15 +56,17 @@ export function useContentAnimation({
const isFirstRender = useRef(true);
// 折叠状态(仅 panel 类型使用)
// true: 面板被折叠
// false: 面板展开
// true: 面板被折叠,点击按钮可展开
// false: 面板展开,点击按钮可折叠
const [isCollapsed, setIsCollapsed] = useState(false);
// 延迟显示的内容状态(仅 panel 类型使用)
// 作用:在内容切换时,先显示旧内容,动画完成后再更新为新内容
// 这样可以实现旧内容离开 -> 新内容进入的平滑过渡
const [displayedContent, setDisplayedContent] = useState(type === "index" ? {} : currentContent);
// 标记是否正在手动处理折叠/展开(防止 useEffect 干预)
// 当手动点击折叠/展开按钮时,设置此标记为 true阻止 useEffect 中的自动动画逻辑
const isManualCollapseAnimating = useRef(false);
// ==================== IndexInfo 类型的缩放动画逻辑 ====================
@ -80,6 +94,7 @@ export function useContentAnimation({
setIsVisible(true);
// 3. 执行放大进入动画(从小到大缩放)
// 使用 requestAnimationFrame 确保在下一帧执行动画,避免在当前帧被覆盖
requestAnimationFrame(() => {
controls.start({
scale: 1, // 放大到正常大小
@ -101,6 +116,9 @@ export function useContentAnimation({
setIsVisible(true);
// 2. 执行放大进入动画
// 使用双重 requestAnimationFrame
// - 第一层:确保状态更新已生效
// - 第二层:确保 set() 操作完成后再执行 start()
requestAnimationFrame(() => {
// 设置元素到初始缩小状态
controls.set({
@ -109,6 +127,7 @@ export function useContentAnimation({
});
// 然后触发放大进入动画(带 0.3s 延迟,等待其他内容离开)
// 延迟时间匹配其他内容的离开动画时长0.3s),确保视觉上的连贯性
requestAnimationFrame(() => {
controls.start({
scale: 1,
@ -147,13 +166,23 @@ export function useContentAnimation({
// ==================== Panel 类型的滑动动画逻辑 ====================
// 辅助函数:计算动画的初始位置
// @returns {number} 初始 X 轴位移值(负数表示在左侧,正数表示在右侧)
// @returns {number} 初始 X 轴位移值
// - 左侧面板:返回 -300从左侧外部进入
// - 右侧面板:返回 300从右侧外部进入
const getInitialX = () => side === "left" ? -300 : 300;
// ==================== 折叠/展开处理 ====================
/**
* 处理折叠按钮点击事件
*
* 折叠/展开动画流程
* 1. 展开菜单按钮先从边缘滑到内容旁边 -> 内容面板从侧边滑入
* 2. 折叠内容面板先滑出到侧边 -> 菜单按钮从内容旁边滑到边缘
*
* 菜单按钮位置说明
* - 展开状态x: 0在内容区旁边left: 465px right: 465px
* - 折叠状态左侧 x: -465屏幕左边缘右侧 x: 465屏幕右边缘
*/
const handleCollapse = () => {
if (isCollapsed) {
@ -170,7 +199,18 @@ export function useContentAnimation({
// 3. 显示元素
setIsVisible(true);
// 4. 使用双重 requestAnimationFrame 确保 DOM 已挂载且 motion 控制器已准备好
// 4. 同步执行菜单按钮动画(从边缘移到内容旁边)
// 左侧按钮left: 465px展开时 x: 0在内容区旁折叠时 x: -465向左到边缘
// 右侧按钮right: 465px展开时 x: 0在内容区旁折叠时 x: 465向右到边缘
menuControls.start({
x: 0, // 移到展开状态位置(内容区旁边)
transition: { duration: 0.5, ease: "easeOut" },
});
// 5. 使用双重 requestAnimationFrame 确保 DOM 已挂载且 motion 控制器已准备好
// 双重 RAF 的作用:
// - 第一层:确保 React 完成状态更新和 DOM 渲染
// - 第二层:确保 controls.set() 的设置被 Framer Motion 识别并应用
requestAnimationFrame(() => {
// 先设置到初始隐藏位置
controls.set({
@ -185,7 +225,7 @@ export function useContentAnimation({
opacity: 1, // 淡入
transition: { duration: 0.5, ease: "easeOut" },
}).then(() => {
// 动画完成后清除手动处理标记
// 动画完成后清除手动处理标记,允许 useEffect 再次响应状态变化
isManualCollapseAnimating.current = false;
});
});
@ -196,26 +236,46 @@ export function useContentAnimation({
// 标记开始手动处理折叠/展开动画(阻止 useEffect 干预)
isManualCollapseAnimating.current = true;
// 同步执行菜单按钮动画(从内容旁边移到边缘)
// 左侧按钮:从 left: 465px 移到 left: 0x: -465 向左到屏幕边缘)
// 右侧按钮:从 right: 465px 移到 right: 0x: 465 向右到屏幕边缘)
menuControls.start({
x: side === "left" ? -465 : 465, // 左侧向左移动,右侧向右移动
transition: { duration: 0.3, ease: "easeIn" },
});
// 执行离开动画(滑到侧边并淡出)
// 注意:菜单按钮和内容面板的动画是并行执行的,同时开始
controls.start({
x: getInitialX(), // 移动到侧边外部
opacity: 0, // 淡出
transition: { duration: 0.3, ease: "easeIn" },
}).then(() => {
// 动画完成后:
// 1. 隐藏元素
// 1. 隐藏元素(从 DOM 中移除或设置 display: none
setIsVisible(false);
// 2. 更新折叠状态为 true
setIsCollapsed(true);
// 3. 清除手动处理标记
// 3. 清除手动处理标记,允许 useEffect 再次响应状态变化
isManualCollapseAnimating.current = false;
});
}
};
// ==================== 主题:监听状态变化并执行动画 ====================
//
// useEffect 的职责:
// 1. 监听 currentContent、isPureMap、isCollapsed 等状态变化
// 2. 根据不同场景自动执行相应的动画
// 3. 不处理手动折叠/展开动画(由 handleCollapse 处理)
//
// 场景优先级(从高到低):
// 1. 纯地图模式:最高优先级,立即隐藏所有面板
// 2. 折叠状态:静默更新数据,不执行动画
// 3. 正常模式:根据内容变化、可见性等执行相应动画
// ====================
useEffect(() => {
// 仅处理 panel 类型
@ -224,6 +284,7 @@ export function useContentAnimation({
}
// 如果正在手动处理折叠/展开动画,不执行 useEffect 逻辑
// 这一步很关键,避免 handleCollapse 和 useEffect 之间的动画冲突
if (isManualCollapseAnimating.current) {
return;
}
@ -253,6 +314,8 @@ export function useContentAnimation({
);
// 静默更新内容(不执行动画,保持数据同步)
// 目的:虽然面板是折叠的,但数据要始终保持最新
// 这样展开时显示的就是最新内容,不需要额外加载
if (contentChanged) {
setDisplayedContent(currentContent);
}
@ -267,6 +330,7 @@ export function useContentAnimation({
}
// ==================== 场景 3正常模式非折叠、非纯地图====================
// 正常模式下,面板应该可见并根据内容变化执行动画
// 检查内容是否真的变化了
const contentChanged = Object.keys(currentContent).some(
@ -281,21 +345,35 @@ export function useContentAnimation({
opacity: 0,
});
// 2. 立即更新显示的内容
// 2. 设置菜单按钮到初始位置(展开状态,在内容区旁边)
// 菜单按钮不执行进入动画,直接显示在最终位置
menuControls.set({
x: 0,
});
// 3. 立即更新显示的内容
setDisplayedContent(currentContent);
// 3. 执行滑入动画(从侧边滑入到正常位置)
// 4. 执行滑入动画(从侧边滑入到正常位置)
controls.start({
x: 0, // 移动到正常位置
opacity: 1, // 淡入
transition: { duration: 0.5, ease: "easeOut" },
});
// 4. 标记首次渲染完成
// 5. 菜单按钮保持在展开状态位置,不需要移动
// 这个 start() 调用确保 menuControls 与动画系统同步
menuControls.start({
x: 0,
transition: { duration: 0.5, ease: "easeOut" },
});
// 6. 标记首次渲染完成
isFirstRender.current = false;
}
// ----- 情况 B内容变化用户切换工具-----
// 动画流程旧内容离开0.3s-> 更新数据 -> 新内容进入0.5s
else if (contentChanged && !isAnimating.current) {
// 标记开始执行动画(防止动画冲突)
isAnimating.current = true;
@ -325,6 +403,7 @@ export function useContentAnimation({
// ----- 情况 C从隐藏状态切换回来退出纯地图模式-----
// 注意:展开操作由 handleCollapse 处理,不在这里处理
// 这里处理的是退出纯地图模式后的自动显示
else if (!isVisible && !isCollapsed) {
// 1. 显示元素
setIsVisible(true);
@ -333,6 +412,7 @@ export function useContentAnimation({
setDisplayedContent(currentContent);
// 3. 执行进入动画
// 使用双重 requestAnimationFrame 确保动画正确执行
requestAnimationFrame(() => {
// 设置元素到初始隐藏位置
controls.set({
@ -353,20 +433,26 @@ export function useContentAnimation({
}, [currentContent, isCollapsed, isPureMap, controls, displayedContent, side, isVisible, type]);
// ==================== 返回值 ====================
//
// 根据类型返回不同的属性:
// - index 类型:只返回 controls 和 isVisible简单动画
// - panel 类型:返回完整的控制属性(包括折叠功能)
// ====================
// 返回值根据类型不同而不同
if (type === "index") {
return {
controls, // motion 动画控制器
isVisible, // 元素可见性状态
controls, // motion 动画控制器,用于 IndexInfo 的缩放动画
isVisible, // 元素可见性状态,控制 IndexInfo 的显示/隐藏
};
}
return {
controls, // motion 动画控制器
controls, // motion 动画控制器,用于内容面板的滑动动画
displayedContent, // 延迟显示的内容(用于动画过渡)
isVisible, // 元素可见性状态
isCollapsed, // 折叠状态
handleCollapse, // 折叠/展开处理函数
isVisible, // 元素可见性状态,控制内容面板的显示/隐藏
isCollapsed, // 折叠状态,标识当前面板是否被折叠
handleCollapse, // 折叠/展开处理函数,响应按钮点击事件
menuControls, // 菜单按钮的动画控制器,用于控制折叠按钮的位置动画
};
}

View File

@ -5,21 +5,37 @@ import { useEffect, useRef, useState } from "react";
* Header 组件动画 Hook
*
* 功能说明
* 1. 监听标题内容变化
* 2. 内容变化时执行上下方向的动画效果向上离开 + 向下进入
* 3. 支持首次加载动画
* 1. 监听标题内容变化执行上下方向的动画效果
* 2. 首次加载时从上方滑入后续切换时旧标题向上离开 + 新标题向下进入
* 3. 使用延迟显示模式确保动画过渡完整可见
*
* 动画效果
* - 进入动画从上方 100px 处滑入同时淡入y: -100 0, opacity: 0 1
* - 离开动画向上方 100px 处滑出同时淡出y: 0 -100, opacity: 1 0
* 动画参数配置
* - 进入动画y: -100 0从上方向下滑入opacity: 0 1淡入
* 时长0.5s缓动easeOut先快后慢营造舒适的进入体验
* - 离开动画y: 0 -100从下方向上滑出opacity: 1 0淡出
* 时长0.3s缓动easeIn先慢后快让用户感觉到响应迅速
*
* 标题切换动画流程
* 1. 旧标题向上离开0.3s向上滑出并淡出
* 2. 更新 displayedTitle 为新标题
* 3. 新标题向下进入0.5s从上方滑入并淡入
*
* @param {string} currentTitle - 当前的标题内容
* @returns {object} 返回包含以下属性的对象
* - controls: motion 动画控制器
* - displayedTitle: 延迟显示的标题用于动画过渡
* - controls: motion 动画控制器绑定到 motion.header animate 属性
* - displayedTitle: 延迟显示的标题用于在动画过渡期间保持视觉连续性
*/
export function useHeaderAnimation(currentTitle) {
// ==================== 状态和引用管理 ====================
// ==================== 状态管理 ====================
//
// 动画时长说明:
// - 进入动画0.5s,较慢,营造舒适的进入体验
// - 离开动画0.3s,较快,让用户感觉到响应迅速
//
// 缓动函数说明:
// - easeOut进入时使用先快后慢更自然
// - easeIn离开时使用先慢后快快速消失
// ====================
// motion 动画控制器,用于控制 motion.header 的动画
const controls = useAnimation();
@ -33,74 +49,79 @@ export function useHeaderAnimation(currentTitle) {
// 这样确保离开动画显示的是旧标题,进入动画显示的是新标题
const [displayedTitle, setDisplayedTitle] = useState(currentTitle);
// ==================== 主题:监听标题变化并执行动画 ====================
//
// useEffect 的职责:
// 1. 监听 currentTitle 的变化
// 2. 根据首次渲染或标题变化执行相应的动画
//
// 场景说明:
// - 首次渲染:初始化并执行进入动画
// - 标题变化:旧标题离开 -> 新标题进入
// ====================
useEffect(() => {
// ==================== 检查标题是否发生变化 ====================
// 检查标题是否发生变化
const titleChanged = displayedTitle !== currentTitle;
// ==================== 首次渲染 ====================
// ==================== 场景 1首次渲染 ====================
if (isFirstRender.current) {
// ===== 步骤 1设置元素到初始隐藏位置上方外部=====
// - y: -100元素位于视图上方 100px 处(隐藏)
// - opacity: 0完全透明
// - controls.set() 是立即设置,不会触发动画过渡
// 1. 设置元素到初始隐藏位置(上方外部)
// controls.set() 是立即设置,不会触发动画过渡
controls.set({
y: -100, // Y 轴位置:从上方 100px 处开始
opacity: 0, // 透明度:完全透明
});
// ===== 步骤 2立即更新显示的标题 =====
// 2. 立即更新显示的标题
// 因为首次渲染不需要保留旧标题,直接设置为传入的标题
setDisplayedTitle(currentTitle);
// ===== 步骤 3执行滑入动画从上方滑入到正常位置=====
// - y: 0从上方移动到正常位置
// - opacity: 1从透明变为不透明
// - duration: 0.5s:动画时长 0.5 秒
// - ease: "easeOut":快速开始,缓慢结束的缓动函数
// 3. 执行滑入动画(从上方滑入到正常位置)
controls.start({
y: 0, // 移动到正常位置
opacity: 1, // 淡入
transition: { duration: 0.5, ease: "easeOut" },
transition: {
duration: 0.5, // 动画时长0.5 秒
ease: "easeOut", // 缓动函数:快速开始,缓慢结束,营造舒适的进入体验
},
});
// ===== 步骤 4标记首次渲染完成 =====
// 4. 标记首次渲染完成
isFirstRender.current = false;
}
// ==================== 标题变化 ====================
// ==================== 场景 2标题变化 ====================
else if (titleChanged) {
// ===== 步骤 1先执行离开动画向上离开=====
// 作用:显示旧标题向上滑出
// - y: -100向上移动到视图外部
// - opacity: 0同时淡出
// - duration: 0.3s:较短的离开动画时长,显得更轻快
// - ease: "easeIn":缓慢开始,快速结束的缓动函数
// 1. 先执行离开动画(旧标题向上离开)
controls.start({
y: -100, // 向上移动到外部
opacity: 0, // 淡出
transition: { duration: 0.3, ease: "easeIn" },
transition: {
duration: 0.3, // 动画时长0.3 秒(比进入动画短,显得更轻快)
ease: "easeIn", // 缓动函数:缓慢开始,快速结束,让用户感觉到响应迅速
},
}).then(() => {
// ===== 步骤 2离开动画完成后更新显示的标题为新标题 =====
// 离开动画完成后:
// 2. 更新显示的标题为新标题
// 此时旧标题已经完全离开视图,更新标题用户看不见
setDisplayedTitle(currentTitle);
// ===== 步骤 3执行进入动画从上方进入=====
// 作用:显示新标题从上方滑入
// - y: 0从上方移动到正常位置
// - opacity: 1从透明变为不透明
// - duration: 0.5s:进入动画时长
// - ease: "easeOut":快速开始,缓慢结束的缓动函数
// 3. 执行进入动画(新标题从上方进入)
controls.start({
y: 0, // 从上方移动到正常位置
opacity: 1, // 淡入
transition: { duration: 0.5, ease: "easeOut" },
transition: {
duration: 0.5, // 动画时长0.5 秒
ease: "easeOut", // 缓动函数:快速开始,缓慢结束
},
});
});
}
}, [currentTitle, controls, displayedTitle]);
// ==================== 返回值 ====================
// 返回动画控制器和延迟显示的标题
return {
controls, // motion 动画控制器,绑定到 motion.header 的 animate 属性
displayedTitle, // 延迟显示的标题,用于在动画过渡期间保持视觉连续性

View File

@ -5,25 +5,26 @@ import { useEffect, useRef } from "react";
* RightUtils 子菜单动画 Hook
*
* 功能说明
* 1. 监听子菜单的展开/收起状态变化
* 2. 执行炫酷的弹跳动画效果从下向上进入从上向下离开
* 3. 支持首次加载动画初始化时就会显示并执行进入动画
* 4. 使用弹跳缩放透明度三重动画效果让动画更加生动
* 1. 监听子菜单的展开/收起状态变化执行炫酷的弹跳动画效果
* 2. 首次加载时从下向上弹跳进入收起时向上弹跳后向下离开
* 3. 使用位置缩放透明度三重动画效果营造生动的视觉体验
*
* 动画效果详解
* 动画参数配置
* - 展开动画进入
* - 位置从下方 80px 处冲到上方 15px超出目标轻微回落到 5px最后稳定到 0
* - 缩放 0.9 倍缩小到 1.05 轻微放大轻微缩小到 0.98回到正常 1.0
* - 透明度从透明到不透明
* - 时长0.7
* - 缓动使用强弹性贝塞尔曲线 [0.34, 1.56, 0.64, 1]产生明显的过冲和回弹效果
* - 位置关键帧y: [80, -15, 5, 0]从下方80px -> 冲到上方15px -> 回落到5px -> 稳定到0
* - 缩放关键帧scale: [0.9, 1.05, 0.98, 1]90% -> 105% -> 98% -> 100%
* - 透明度关键帧opacity: [0, 1, 1, 1]透明 -> 不透明
* - 时长0.7s缓动[0.34, 1.56, 0.64, 1]强弹性曲线产生过冲和回弹
*
* - 收起动画离开
* - 位置从正常位置向上弹跳 20px回落到 10px然后向下离开到 100px
* - 缩放轻微放大到 1.02然后缩小到 0.85
* - 透明度保持可见再淡出
* - 时长0.5
* - 缓动使用超弹性曲线 [0.68, -0.6, 0.32, 1.6]产生夸张的反向弹跳
* - 位置关键帧y: [0, -20, 10, 100]正常位置 -> 向上弹跳20px -> 回落到10px -> 向下离开到100px
* - 缩放关键帧scale: [1, 1.02, 0.95, 0.85]100% -> 102% -> 95% -> 85%
* - 透明度关键帧opacity: [1, 1, 0.8, 0]不透明 -> 淡出
* - 时长0.5s缓动[0.68, -0.6, 0.32, 1.6]超弹性曲线产生夸张的反向弹跳
*
* 关键帧时间点分布
* - 展开动画times: [0, 0.5, 0.75, 1]0% -> 50% -> 75% -> 100%
* - 收起动画times: [0, 0.2, 0.5, 1]0% -> 20% -> 50% -> 100%
*
* @param {boolean} isVisible - 子菜单是否可见
* - true: 子菜单应该展开执行展开动画
@ -32,7 +33,21 @@ import { useEffect, useRef } from "react";
* - controls: motion 动画控制器绑定到 motion.div animate 属性
*/
export function useChildMenuAnimation(isVisible) {
// ==================== 状态和引用管理 ====================
// ==================== 状态管理 ====================
//
// 动画时长说明:
// - 展开动画0.7s,较慢,展示完整的弹跳效果
// - 收起动画0.5s,较快,让用户感觉到响应迅速
//
// 缓动函数说明:
// - 展开动画:[0.34, 1.56, 0.64, 1],强弹性曲线,产生明显的过冲和回弹
// - 收起动画:[0.68, -0.6, 0.32, 1.6],超弹性曲线,产生夸张的反向弹跳
//
// 三重动画效果:
// - 位置动画y产生弹跳的物理运动效果
// - 缩放动画scale增强弹跳的视觉冲击力
// - 透明度动画opacity平滑的进入和离开
// ====================
// motion 动画控制器,用于控制 motion.div 的动画
const controls = useAnimation();
@ -45,11 +60,23 @@ export function useChildMenuAnimation(isVisible) {
// 作用:首次渲染时需要特殊处理,确保初始状态正确设置
const isFirstRender = useRef(true);
// ==================== 主题:监听可见性变化并执行弹跳动画 ====================
//
// useEffect 的职责:
// 1. 监听 isVisible 的变化
// 2. 根据展开/收起状态执行相应的弹跳动画
//
// 场景说明:
// - 首次渲染:初始化并执行展开动画
// - 展开状态:执行从下向上的弹跳进入动画
// - 收起状态:执行向上弹跳后向下离开的动画
// ====================
useEffect(() => {
// ==================== 首次渲染 ====================
// ==================== 场景 1首次渲染 ====================
if (isFirstRender.current) {
if (isVisible) {
// ===== 执行展开动画:从下向上炫酷弹跳进入 =====
// 执行展开动画:从下向上炫酷弹跳进入
controls.start({
// Y 轴位置关键帧(四个关键帧实现弹跳效果):
// ① 80从下方 80px 处开始
@ -74,6 +101,7 @@ export function useChildMenuAnimation(isVisible) {
transition: {
duration: 0.7, // 总动画时长0.7 秒
// 关键帧时间点分布:
// ① 00% 时0s在 y=80 处
// ② 0.550% 时0.35s)在 y=-15 处(过冲峰值)
@ -90,7 +118,7 @@ export function useChildMenuAnimation(isVisible) {
ease: [0.34, 1.56, 0.64, 1],
},
}).then(() => {
// ===== 动画完成,标记状态 =====
// 动画完成后标记状态
isExpanded.current = true; // 标记已展开
isFirstRender.current = false; // 标记首次渲染完成
});
@ -102,9 +130,9 @@ export function useChildMenuAnimation(isVisible) {
return;
}
// ==================== 展开动画(非首次渲染)====================
// ==================== 场景 2展开动画(非首次渲染)====================
if (isVisible && !isExpanded.current) {
// ===== 从下向上炫酷弹跳进入 =====
// 从下向上炫酷弹跳进入
controls.start({
y: [80, -15, 5, 0], // 位置下方80px -> 上方15px过冲-> 下方5px回落-> 正常
opacity: [0, 1, 1, 1], // 透明度:透明 -> 不透明 -> 不透明 -> 不透明
@ -119,9 +147,9 @@ export function useChildMenuAnimation(isVisible) {
});
}
// ==================== 收起动画 ====================
// ==================== 场景 3收起动画 ====================
else if (!isVisible && isExpanded.current) {
// ===== 从上向下炫酷弹跳离开 =====
// 从上向下炫酷弹跳离开
controls.start({
// Y 轴位置关键帧:
// ① 0从正常位置开始
@ -167,8 +195,8 @@ export function useChildMenuAnimation(isVisible) {
}
}, [isVisible, controls]);
// 返回动画控制器
// ==================== 返回值 ====================
return {
controls,
controls, // motion 动画控制器,绑定到 motion.div 的 animate 属性
};
}

View File

@ -5,31 +5,40 @@ import { useEffect, useRef, useState } from "react";
* RightUtils 组件动画 Hook
*
* 功能说明
* 1. 监听显示/隐藏状态变化
* 2. 执行从右侧进入从右侧离开的动画效果
* 3. 支持模式切换港口工具 分公司工具
* 4. 支持首次加载动画
* 5. 初始化时就会显示并执行进入动画
* 1. 监听显示/隐藏状态变化执行从右侧进入/离开的滑动动画
* 2. 支持模式切换港口工具 分公司工具实现平滑过渡
* 3. 支持首次加载动画初始化时自动进入
* 4. 使用延迟显示模式确保动画过渡完整可见
*
* 动画效果
* - 进入动画从右侧 100px 处滑入同时淡入x: 100 0, opacity: 0 1
* - 离开动画向右侧 100px 处滑出同时淡出x: 0 100, opacity: 1 0
* 动画参数配置
* - 进入动画x: 100 0从右向左滑入opacity: 0 1淡入
* 时长0.5s缓动easeOut先快后慢营造舒适的进入体验
* - 离开动画x: 0 100从左向右滑出opacity: 1 0淡出
* 时长0.3s缓动easeIn先慢后快让用户感觉到响应迅速
*
* 模式切换流程
* 1. 旧模式执行离开动画0.3
* 2. 离开动画完成后更新 displayedMode 为新模式
* 3. 设置元素到初始位置右侧 100px
* 4. 执行新模式的进入动画0.5
* 模式切换动画流程
* 1. 旧模式离开0.3s向右滑出并淡出
* 2. 更新 displayedMode 为新模式
* 3. 新模式进入0.5s从右侧滑入并淡入
*
* @param {boolean} shouldShowPort - 是否应该显示港口工具
* @param {boolean} shouldShowBranchOffice - 是否应该显示分公司工具
* @returns {object} 返回包含以下属性的对象
* - controls: motion 动画控制器
* - displayedMode: 延迟显示的模式'port' | 'branchOffice' | null
* - isVisible: 元素可见性状态
* - controls: motion 动画控制器绑定到 motion.div animate 属性
* - displayedMode: 延迟显示的模式'port' | 'branchOffice' | null用于决定渲染内容
* - isVisible: 元素可见性状态用于控制内容的显示/隐藏
*/
export function useRightUtilsAnimation(shouldShowPort, shouldShowBranchOffice) {
// ==================== 状态和引用管理 ====================
// ==================== 状态管理 ====================
//
// 动画时长说明:
// - 进入动画0.5s,较慢,营造舒适的进入体验
// - 离开动画0.3s,较快,让用户感觉到响应迅速
//
// 缓动函数说明:
// - easeOut进入时使用先快后慢更自然
// - easeIn离开时使用先慢后快快速消失
// ====================
// motion 动画控制器,用于控制 motion.div 的动画
const controls = useAnimation();
@ -44,30 +53,42 @@ export function useRightUtilsAnimation(shouldShowPort, shouldShowBranchOffice) {
// 延迟显示的模式状态
// 作用:在模式切换时,先显示旧模式,动画完成后再更新为新模式
// 这样确保用户看到完整的离开动画和进入动画
// 这样确保用户看到完整的离开动画和进入动画,实现平滑过渡
const [displayedMode, setDisplayedMode] = useState(
shouldShowPort ? "port" : shouldShowBranchOffice ? "branchOffice" : null,
);
// 元素可见性状态
// 注意motion.div 始终挂载isVisible 只控制内容的显示/隐藏
// 这样可以保证动画控制器始终有效
// 这样可以保证动画控制器始终有效,避免重复创建/销毁
const [isVisible, setIsVisible] = useState(false);
// ==================== 主题:监听状态变化并执行动画 ====================
//
// useEffect 的职责:
// 1. 监听 shouldShowPort 和 shouldShowBranchOffice 的变化
// 2. 根据不同场景自动执行相应的动画
//
// 场景说明:
// - 首次渲染:初始化并执行进入动画
// - 隐藏状态:执行离开动画并隐藏元素
// - 模式切换:旧模式离开 -> 新模式进入
// - 显示状态:从隐藏切换到可见
// ====================
useEffect(() => {
// ==================== 计算当前应该显示的模式 ====================
// 根据 shouldShowPort 和 shouldShowBranchOffice 判断当前应该显示哪个模式
// 优先级:港口工具 > 分公司工具 > 隐藏
const currentMode = shouldShowPort ? "port" : shouldShowBranchOffice ? "branchOffice" : null;
// ==================== 首次渲染 ====================
// ==================== 场景 1首次渲染 ====================
if (isFirstRender.current) {
if (currentMode) {
// ===== 步骤 1立即更新显示的模式 =====
// ----- 步骤 1立即更新显示的模式 -----
// 设置 displayedMode 为当前模式,让组件知道应该渲染哪种内容
setDisplayedMode(currentMode);
// ===== 步骤 2设置初始动画状态 =====
// 此时 motion.div 还没有挂载(因为 isVisible 还是 false
// ----- 步骤 2设置初始动画状态 -----
// controls.set() 会将初始状态保存到 controls 对象中
// 当 motion.div 挂载时,会读取这个初始状态
controls.set({
@ -75,22 +96,22 @@ export function useRightUtilsAnimation(shouldShowPort, shouldShowBranchOffice) {
opacity: 0, // 透明度:完全透明
});
// ===== 步骤 3显示元素触发 motion.div 挂载 =====
// ----- 步骤 3显示元素触发 motion.div 挂载 -----
// 设置 isVisible 为 true触发组件重渲染
// motion.div 会挂载到 DOM并读取 controls 的初始状态 { x: 100, opacity: 0 }
// motion.div 会挂载到 DOM并读取 controls 的初始状态
setIsVisible(true);
// ===== 步骤 4执行进入动画 =====
// ----- 步骤 4执行进入动画 -----
// 从初始位置x: 100, opacity: 0动画到正常位置x: 0, opacity: 1
controls.start({
x: 0, // 移动到正常位置X 轴 0
opacity: 1, // 变为完全不透明
transition: {
duration: 0.5, // 动画时长0.5 秒
ease: "easeOut", // 缓动函数:快速开始,缓慢结束
ease: "easeOut", // 缓动函数:快速开始,缓慢结束,营造舒适的进入体验
},
}).then(() => {
// ===== 步骤 5动画完成标记首次渲染结束 =====
// ----- 步骤 5动画完成标记首次渲染结束 -----
isFirstRender.current = false;
});
}
@ -102,38 +123,46 @@ export function useRightUtilsAnimation(shouldShowPort, shouldShowBranchOffice) {
return;
}
// ==================== 隐藏状态(当前没有工具应该显示)====================
// ==================== 场景 2隐藏状态(当前没有工具应该显示)====================
if (!currentMode) {
if (isVisible && !isAnimating.current) {
// ===== 执行离开动画 =====
// 标记开始执行动画(防止动画冲突)
isAnimating.current = true;
// 执行离开动画
controls.start({
x: 100, // 向右移动到 100px 处
opacity: 0, // 同时淡出
transition: {
duration: 0.3, // 动画时长0.3 秒(比进入动画短,显得更轻快)
ease: "easeIn", // 缓动函数:缓慢开始,快速结束
ease: "easeIn", // 缓动函数:缓慢开始,快速结束,让用户感觉到响应迅速
},
}).then(() => {
// ===== 离开动画完成后,隐藏元素 =====
setIsVisible(false); // 隐藏内容motion.div 仍然挂载)
setDisplayedMode(null); // 清空显示的模式
isAnimating.current = false; // 解除动画锁定
// 离开动画完成后:
// 1. 隐藏内容motion.div 仍然挂载,避免重新创建)
setIsVisible(false);
// 2. 清空显示的模式
setDisplayedMode(null);
// 3. 解除动画锁定,允许后续动画
isAnimating.current = false;
});
}
return;
}
// ==================== 模式切换或显示 ====================
// ==================== 场景 3模式切换或显示 ====================
// 检查模式是否发生了变化(港口 ↔ 分公司)
const modeChanged = displayedMode !== currentMode;
// ===== 场景 1模式切换港口 ↔ 分公司)=====
// ----- 情况 A模式切换港口 ↔ 分公司)-----
if (modeChanged && !isAnimating.current && isVisible) {
// 标记开始执行动画(防止动画冲突)
isAnimating.current = true;
// -------- 步骤 1执行离开动画旧模式离开--------
// 1. 执行离开动画(旧模式离开)
controls.start({
x: 100, // 向右移动到 100px 处
opacity: 0, // 同时淡出
@ -142,16 +171,18 @@ export function useRightUtilsAnimation(shouldShowPort, shouldShowBranchOffice) {
ease: "easeIn", // 缓动函数
},
}).then(() => {
// -------- 步骤 2离开动画完成后更新显示的模式 --------
// 离开动画完成后:
// 2. 更新显示的模式为新模式
setDisplayedMode(currentMode);
// -------- 步骤 3设置元素到初始位置右侧外部--------
// 3. 设置元素到初始位置(右侧外部)
controls.set({
x: 100, // 确保元素在右侧 100px 处
opacity: 0, // 确保元素是透明的
});
// -------- 步骤 4执行进入动画新模式进入--------
// 4. 执行进入动画(新模式进入)
controls.start({
x: 0, // 移动到正常位置
opacity: 1, // 淡入
@ -160,27 +191,27 @@ export function useRightUtilsAnimation(shouldShowPort, shouldShowBranchOffice) {
ease: "easeOut", // 缓动函数
},
}).then(() => {
// -------- 步骤 5动画完成 --------
isAnimating.current = false; // 解除动画锁定
// 动画完成后解除锁定
isAnimating.current = false;
});
});
}
// ===== 场景 2元素不可见但应该显示例如从隐藏状态切换到显示=====
// ----- 情况 B元素不可见但应该显示例如从隐藏状态切换到显示-----
else if (!isVisible && currentMode) {
// -------- 步骤 1显示元素 --------
// 1. 显示元素
setIsVisible(true);
// -------- 步骤 2更新显示的模式 --------
// 2. 更新显示的模式
setDisplayedMode(currentMode);
// -------- 步骤 3设置元素到初始位置 --------
// 3. 设置元素到初始位置
controls.set({
x: 100, // 右侧 100px 处
opacity: 0, // 透明
});
// -------- 步骤 4执行进入动画 --------
// 4. 执行进入动画
controls.start({
x: 0, // 移动到正常位置
opacity: 1, // 淡入
@ -193,7 +224,6 @@ export function useRightUtilsAnimation(shouldShowPort, shouldShowBranchOffice) {
}, [shouldShowPort, shouldShowBranchOffice, controls, displayedMode, isVisible]);
// ==================== 返回值 ====================
// 返回动画控制器、延迟显示的模式和可见性状态
return {
controls, // motion 动画控制器,绑定到 motion.div 的 animate 属性
displayedMode, // 延迟显示的模式,用于决定渲染 port 还是 branchOffice 内容