diff --git a/src/pages/Container/Map/components/BottomUtils/useBranchOfficeUtilsAnimation.js b/src/pages/Container/Map/components/BottomUtils/useBranchOfficeUtilsAnimation.js index 48f94b5..4baa324 100644 --- a/src/pages/Container/Map/components/BottomUtils/useBranchOfficeUtilsAnimation.js +++ b/src/pages/Container/Map/components/BottomUtils/useBranchOfficeUtilsAnimation.js @@ -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 → 0,opacity: 0 → 1,时长:0.3s + * - 子项离开动画:y: 0 → 50,opacity: 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 时 - // Framer Motion 会根据当前是进入还是离开自动选择对应的 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 属性 diff --git a/src/pages/Container/Map/components/Content/index.js b/src/pages/Container/Map/components/Content/index.js index 283bea1..82cce10 100644 --- a/src/pages/Container/Map/components/Content/index.js +++ b/src/pages/Container/Map/components/Content/index.js @@ -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 ( -
handleLeftCollapse()} + animate={leftMenuControls} + onClick={handleLeftCollapse} + initial={{ x: 0 }} > - -
+ + ); } @@ -175,22 +189,46 @@ function Content(props) { return ( <> {bottomUtilsCurrentType !== "camera" && ( -
handleLeftCollapse()} + animate={leftMenuControls} + onClick={handleLeftCollapse} + initial={{ x: 0 }} > - -
+ + )} {bottomUtilsCurrentIndex === -1 && ( -
handleRightCollapse()} + animate={rightMenuControls} + onClick={handleRightCollapse} + initial={{ x: 0, rotate: 180 }} > - -
+ + )} ); diff --git a/src/pages/Container/Map/components/Content/index.less b/src/pages/Container/Map/components/Content/index.less index 870a982..65d5c49 100644 --- a/src/pages/Container/Map/components/Content/index.less +++ b/src/pages/Container/Map/components/Content/index.less @@ -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; diff --git a/src/pages/Container/Map/components/Content/useContentAnimation.js b/src/pages/Container/Map/components/Content/useContentAnimation.js index 2ab9d4e..9e0ac3a 100644 --- a/src/pages/Container/Map/components/Content/useContentAnimation.js +++ b/src/pages/Container/Map/components/Content/useContentAnimation.js @@ -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: 0(x: -465 向左到屏幕边缘) + // 右侧按钮:从 right: 465px 移到 right: 0(x: 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, // 菜单按钮的动画控制器,用于控制折叠按钮的位置动画 }; } diff --git a/src/pages/Container/Map/components/Header/useHeaderAnimation.js b/src/pages/Container/Map/components/Header/useHeaderAnimation.js index 7277606..40c9fe2 100644 --- a/src/pages/Container/Map/components/Header/useHeaderAnimation.js +++ b/src/pages/Container/Map/components/Header/useHeaderAnimation.js @@ -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, // 延迟显示的标题,用于在动画过渡期间保持视觉连续性 diff --git a/src/pages/Container/Map/components/RightUtils/useChildMenuAnimation.js b/src/pages/Container/Map/components/RightUtils/useChildMenuAnimation.js index 81bb30d..64a6ceb 100644 --- a/src/pages/Container/Map/components/RightUtils/useChildMenuAnimation.js +++ b/src/pages/Container/Map/components/RightUtils/useChildMenuAnimation.js @@ -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 秒 + // 关键帧时间点分布: // ① 0:0% 时(0s)在 y=80 处 // ② 0.5:50% 时(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 属性 }; } diff --git a/src/pages/Container/Map/components/RightUtils/useRightUtilsAnimation.js b/src/pages/Container/Map/components/RightUtils/useRightUtilsAnimation.js index 30e1424..378e1e6 100644 --- a/src/pages/Container/Map/components/RightUtils/useRightUtilsAnimation.js +++ b/src/pages/Container/Map/components/RightUtils/useRightUtilsAnimation.js @@ -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 内容