From 476ead95b5b85e77b0e511d69404547ffcf0137b Mon Sep 17 00:00:00 2001 From: LiuJiaNan <15703339975@163.com> Date: Wed, 15 Jul 2026 10:17:00 +0800 Subject: [PATCH] =?UTF-8?q?refactor(animation):=20=E4=BD=BF=E7=94=A8=20Ani?= =?UTF-8?q?matePresence=20=E6=9B=BF=E6=8D=A2=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E5=8A=A8=E7=94=BB=E6=8E=A7=E5=88=B6=E5=99=A8=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E5=8A=A8=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 useBottomUtilsAnimation、useCenterUtilsAnimation、useContentAnimation、useHeaderAnimation、useRightUtilsAnimation 等自定义动画 Hook,统一使用 AnimatePresence 实现动画流程控制 --- .../Map/components/BottomUtils/index.js | 80 +-- .../BottomUtils/useBottomUtilsAnimation.js | 223 --------- .../useBranchOfficeUtilsAnimation.js | 116 ----- .../BottomUtils/usePortUtilsAnimation.js | 2 +- .../Map/components/CenterUtils/index.js | 75 +-- .../CenterUtils/useCenterUtilsAnimation.js | 205 -------- .../Container/Map/components/Content/index.js | 319 +++++------- .../components/Content/useContentAnimation.js | 458 ------------------ .../Container/Map/components/Header/index.js | 48 +- .../components/Header/useHeaderAnimation.js | 129 ----- .../Map/components/RightUtils/index.js | 90 ++-- .../RightUtils/useChildMenuAnimation.js | 230 --------- .../RightUtils/useRightUtilsAnimation.js | 232 --------- 13 files changed, 307 insertions(+), 1900 deletions(-) delete mode 100644 src/pages/Container/Map/components/BottomUtils/useBottomUtilsAnimation.js delete mode 100644 src/pages/Container/Map/components/BottomUtils/useBranchOfficeUtilsAnimation.js delete mode 100644 src/pages/Container/Map/components/CenterUtils/useCenterUtilsAnimation.js delete mode 100644 src/pages/Container/Map/components/Content/useContentAnimation.js delete mode 100644 src/pages/Container/Map/components/Header/useHeaderAnimation.js delete mode 100644 src/pages/Container/Map/components/RightUtils/useChildMenuAnimation.js delete mode 100644 src/pages/Container/Map/components/RightUtils/useRightUtilsAnimation.js diff --git a/src/pages/Container/Map/components/BottomUtils/index.js b/src/pages/Container/Map/components/BottomUtils/index.js index 4e035d0..bf5c432 100644 --- a/src/pages/Container/Map/components/BottomUtils/index.js +++ b/src/pages/Container/Map/components/BottomUtils/index.js @@ -14,29 +14,44 @@ import { resetBottomCurrentIndexMittKey, } from "~/pages/Container/Map/js/mittKey"; import { portUtilsList } from "./portUtilsList"; -import { useBottomUtilsAnimation } from "./useBottomUtilsAnimation"; -import { useBranchOfficeUtilsAnimation } from "./useBranchOfficeUtilsAnimation"; import { usePortUtilsAnimation } from "./usePortUtilsAnimation"; import "./index.less"; +const bottomUtilsAnimation = { + initial: { y: 100, opacity: 0 }, + animate: { y: 0, opacity: 1, transition: { duration: 0.5, ease: "easeOut" } }, + exit: { y: 100, opacity: 0, transition: { duration: 0.3, ease: "easeIn" } }, +}; + +// 分公司子项按顺序进入、反向退出,保留原来的波浪式效果。 +const branchOfficeContainerAnimation = { + hidden: { + y: 200, + opacity: 0, + transition: { duration: 0.2, ease: "easeIn", staggerChildren: 0.03, staggerDirection: -1 }, + }, + visible: { + y: 0, + opacity: 1, + transition: { duration: 0.3, ease: "easeOut", staggerChildren: 0.05, staggerDirection: 1 }, + }, +}; + +const branchOfficeItemAnimation = { + hidden: { y: 50, opacity: 0 }, + visible: { y: 0, opacity: 1, transition: { duration: 0.3, ease: "easeOut" } }, +}; + function BottomUtils(props) { const { currentPort, pureMap, currentBranchOffice, bottomUtilsCurrentIndex } = useContext(Context); - const [list, setList] = useState([]); - - const { - controls: bottomUtilsControls, - displayedMode: bottomUtilsDisplayedMode, - isVisible: bottomUtilsIsVisible, - } = useBottomUtilsAnimation( - !pureMap && currentPort && !currentBranchOffice, - !pureMap && currentPort && currentBranchOffice, - ); - - const { - containerVariants: branchOfficeUtilsContainerVariants, - itemVariants: branchOfficeUtilsChildItemVariants, - } = useBranchOfficeUtilsAnimation(); + const bottomUtilsMode = !pureMap && currentPort && !currentBranchOffice + ? "port" + : !pureMap && currentPort && currentBranchOffice ? "branchOffice" : null; + const [list, setList] = useState(() => bottomUtilsMode === "port" + ? portUtilsList + : bottomUtilsMode === "branchOffice" ? branchOfficeUtilsList : []); + const [listMode, setListMode] = useState(bottomUtilsMode); const { parentControls: portUtilsParentControls, @@ -49,7 +64,7 @@ function BottomUtils(props) { } = usePortUtilsAnimation( portUtilsList.length, bottomUtilsCurrentIndex, - bottomUtilsDisplayedMode === "port", + bottomUtilsMode === "port", ); const resetAllCheck = () => { @@ -63,16 +78,17 @@ function BottomUtils(props) { }; useEffect(() => { - if (bottomUtilsDisplayedMode === "port") { + if (bottomUtilsMode === "port") { setList(portUtilsList); } - else if (bottomUtilsDisplayedMode === "branchOffice") { + else if (bottomUtilsMode === "branchOffice") { setList(branchOfficeUtilsList); } else { setList([]); } - }, [bottomUtilsDisplayedMode]); + setListMode(bottomUtilsMode); + }, [bottomUtilsMode]); useEffect(() => { mitt.on(resetBottomCurrentIndexMittKey, () => { @@ -103,7 +119,7 @@ function BottomUtils(props) { }; const renderPortUtils = () => { - if (bottomUtilsDisplayedMode === "port") { + if (bottomUtilsMode === "port" && listMode === bottomUtilsMode) { return list.map((item, index) => { const isCurrentActive = bottomUtilsCurrentIndex === index; const hasActiveChildren = bottomUtilsCurrentIndex !== -1; @@ -158,7 +174,7 @@ function BottomUtils(props) { }; const renderBranchOfficeUtils = () => { - if (bottomUtilsDisplayedMode === "branchOffice") { + if (bottomUtilsMode === "branchOffice" && listMode === bottomUtilsMode) { return ( list.map((item, index) => { const isCurrentActive = bottomUtilsCurrentIndex === index; @@ -172,7 +188,7 @@ function BottomUtils(props) {
{item.label}
{(isCurrentActive && item.list?.length > 0) && ( - + {item.list?.map((item1, index1) => { return (
{item1.label}
@@ -200,14 +216,18 @@ function BottomUtils(props) { return (
- - {bottomUtilsIsVisible && ( - <> + + {bottomUtilsMode && ( + {renderPortUtils()} {renderBranchOfficeUtils()} - + )} - +
); } diff --git a/src/pages/Container/Map/components/BottomUtils/useBottomUtilsAnimation.js b/src/pages/Container/Map/components/BottomUtils/useBottomUtilsAnimation.js deleted file mode 100644 index a4826dd..0000000 --- a/src/pages/Container/Map/components/BottomUtils/useBottomUtilsAnimation.js +++ /dev/null @@ -1,223 +0,0 @@ -import { useAnimation } from "motion/react"; -import { useEffect, useRef, useState } from "react"; - -/** - * BottomUtils 组件动画 Hook - * - * 功能说明: - * 1. 监听显示/隐藏状态变化,执行从下向上进入、从上向下离开的滑动动画 - * 2. 支持模式切换(港口工具 ↔ 分公司工具),实现平滑过渡 - * 3. 支持首次加载动画,初始化时自动进入 - * 4. 使用延迟显示模式,确保动画过渡完整可见 - * - * 动画参数配置: - * - 进入动画:y: 100 → 0(从下向上滑入),opacity: 0 → 1(淡入) - * 时长:0.5s,缓动:easeOut(先快后慢,营造舒适的进入体验) - * - 离开动画:y: 0 → 100(从上向下滑出),opacity: 1 → 0(淡出) - * 时长:0.3s,缓动:easeIn(先慢后快,让用户感觉到响应迅速) - * - * 模式切换动画流程: - * 1. 旧模式离开(0.3s):向下滑出并淡出 - * 2. 更新 displayedMode 为新模式 - * 3. 新模式进入(0.5s):从下方滑入并淡入 - * - * @param {boolean} shouldShowPort - 是否应该显示港口工具 - * @param {boolean} shouldShowBranchOffice - 是否应该显示分公司工具 - * @returns {object} 返回包含以下属性的对象: - * - controls: motion 动画控制器,绑定到 motion.div 的 animate 属性 - * - displayedMode: 延迟显示的模式,用于决定渲染 port 还是 branchOffice 内容 - * - isVisible: 元素可见性状态,用于控制内容的显示/隐藏 - */ -export function useBottomUtilsAnimation(shouldShowPort, shouldShowBranchOffice) { - // ==================== 状态管理 ==================== - // - // 动画时长说明: - // - 进入动画:0.5s,较慢,营造舒适的进入体验 - // - 离开动画:0.3s,较快,让用户感觉到响应迅速 - // - // 缓动函数说明: - // - easeOut:进入时使用,先快后慢,更自然 - // - easeIn:离开时使用,先慢后快,快速消失 - // ==================== - - // motion 动画控制器,用于控制 motion.div 的动画 - const controls = useAnimation(); - - // 标记是否是首次渲染 - // 作用:首次渲染时需要特殊处理,确保初始状态正确设置 - const isFirstRender = useRef(true); - - // 标记是否正在执行动画 - // 作用:防止在动画执行过程中触发新的动画,避免动画冲突 - const isAnimating = useRef(false); - - // 延迟显示的模式状态 - // 作用:在模式切换时,先显示旧模式,动画完成后再更新为新模式 - // 这样确保用户看到完整的离开动画和进入动画,实现平滑过渡 - 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; - - // ==================== 首次渲染 ==================== - if (isFirstRender.current) { - if (currentMode) { - // ===== 步骤 1:立即更新显示的模式 ===== - // 设置 displayedMode 为当前模式,让组件知道应该渲染哪种内容 - setDisplayedMode(currentMode); - - // ===== 步骤 2:设置初始动画状态 ===== - // 此时 motion.div 还没有挂载(因为 isVisible 还是 false) - // controls.set() 会将初始状态保存到 controls 对象中 - // 当 motion.div 挂载时,会读取这个初始状态 - controls.set({ - y: 100, // Y 轴位置:从下方 100px 处开始 - opacity: 0, // 透明度:完全透明 - }); - - // ===== 步骤 3:显示元素,触发 motion.div 挂载 ===== - // 设置 isVisible 为 true,触发组件重渲染 - // motion.div 会挂载到 DOM,并读取 controls 的初始状态 { y: 100, opacity: 0 } - setIsVisible(true); - - // ===== 步骤 4:执行进入动画 ===== - // 从初始位置(y: 100, opacity: 0)动画到正常位置(y: 0, opacity: 1) - controls.start({ - y: 0, // 移动到正常位置(Y 轴 0) - opacity: 1, // 变为完全不透明 - transition: { - duration: 0.5, // 动画时长:0.5 秒 - ease: "easeOut", // 缓动函数:快速开始,缓慢结束 - }, - }).then(() => { - // ===== 步骤 5:动画完成,标记首次渲染结束 ===== - isFirstRender.current = false; - }); - } - else { - // 如果首次渲染时不需要显示(currentMode 为 null) - // 直接标记首次渲染完成,不需要执行动画 - isFirstRender.current = false; - } - return; - } - - // ==================== 隐藏状态(当前没有工具应该显示)==================== - if (!currentMode) { - if (isVisible && !isAnimating.current) { - // ===== 执行离开动画 ===== - isAnimating.current = true; - controls.start({ - y: 100, // 向下移动到 100px 处 - opacity: 0, // 同时淡出 - transition: { - duration: 0.3, // 动画时长:0.3 秒(比进入动画短,显得更轻快) - ease: "easeIn", // 缓动函数:缓慢开始,快速结束 - }, - }).then(() => { - // ===== 离开动画完成后,隐藏元素 ===== - setIsVisible(false); // 隐藏内容(motion.div 仍然挂载) - setDisplayedMode(null); // 清空显示的模式 - isAnimating.current = false; // 解除动画锁定 - }); - } - return; - } - - // ==================== 模式切换或显示 ==================== - - // 检查模式是否发生了变化(港口 ↔ 分公司) - const modeChanged = displayedMode !== currentMode; - - // ===== 场景 1:模式切换(港口 ↔ 分公司)===== - if (modeChanged && !isAnimating.current && isVisible) { - isAnimating.current = true; - - // -------- 步骤 1:执行离开动画(旧模式离开)-------- - controls.start({ - y: 100, // 向下移动到 100px 处 - opacity: 0, // 同时淡出 - transition: { - duration: 0.3, // 动画时长:0.3 秒 - ease: "easeIn", // 缓动函数 - }, - }).then(() => { - // -------- 步骤 2:离开动画完成后,更新显示的模式 -------- - setDisplayedMode(currentMode); - - // -------- 步骤 3:设置元素到初始位置(下方外部)-------- - controls.set({ - y: 100, // 确保元素在下方 100px 处 - opacity: 0, // 确保元素是透明的 - }); - - // -------- 步骤 4:执行进入动画(新模式进入)-------- - controls.start({ - y: 0, // 移动到正常位置 - opacity: 1, // 淡入 - transition: { - duration: 0.5, // 动画时长:0.5 秒 - ease: "easeOut", // 缓动函数 - }, - }).then(() => { - // -------- 步骤 5:动画完成 -------- - isAnimating.current = false; // 解除动画锁定 - }); - }); - } - - // ===== 场景 2:元素不可见,但应该显示(例如:从隐藏状态切换到显示)===== - else if (!isVisible && currentMode) { - // -------- 步骤 1:显示元素 -------- - setIsVisible(true); - - // -------- 步骤 2:更新显示的模式 -------- - setDisplayedMode(currentMode); - - // -------- 步骤 3:设置元素到初始位置 -------- - controls.set({ - y: 100, // 下方 100px 处 - opacity: 0, // 透明 - }); - - // -------- 步骤 4:执行进入动画 -------- - controls.start({ - y: 0, // 移动到正常位置 - opacity: 1, // 淡入 - transition: { - duration: 0.5, // 动画时长:0.5 秒 - ease: "easeOut", // 缓动函数 - }, - }); - } - }, [shouldShowPort, shouldShowBranchOffice, controls, displayedMode, isVisible]); - - // ==================== 返回值 ==================== - return { - controls, // motion 动画控制器,绑定到 motion.div 的 animate 属性 - displayedMode, // 延迟显示的模式,用于决定渲染 port 还是 branchOffice 内容 - isVisible, // 元素可见性状态,用于控制内容的显示/隐藏 - }; -} diff --git a/src/pages/Container/Map/components/BottomUtils/useBranchOfficeUtilsAnimation.js b/src/pages/Container/Map/components/BottomUtils/useBranchOfficeUtilsAnimation.js deleted file mode 100644 index 4baa324..0000000 --- a/src/pages/Container/Map/components/BottomUtils/useBranchOfficeUtilsAnimation.js +++ /dev/null @@ -1,116 +0,0 @@ -/** - * BottomUtils 子项动画 Hook(分公司工具栏) - * - * 功能说明: - * 1. 为底部工具栏子项列表提供从下向上进入、从上向下离开的动画效果 - * 2. 实现子项依次出现的波浪式动画(stagger 效果) - * 3. 离开时反向依次消失,营造流畅的视觉体验 - * - * 动画参数配置: - * - 容器进入动画: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 波浪效果说明: - * - 进入时(visible):子项从第一个到最后一个依次出现,每个间隔 0.05 秒 - * - 离开时(hidden):子项从最后一个到第一个依次消失,每个间隔 0.03 秒(更快,感觉更敏捷) - * - * 使用方法: - * 1. 在父组件中使用 AnimatePresence 包裹子项容器 - * 2. 在子项容器上设置 initial="hidden"、animate="visible"、exit="hidden" - * 3. 在子项容器上使用 variants={containerVariants} - * 4. 在每个子项上使用 variants={itemVariants} - * - * @returns {object} 返回包含以下属性的对象: - * - containerVariants: 容器的动画变体,用于 motion.div 的 variants 属性 - * - itemVariants: 子项的动画变体,用于每个子项 motion.div 的 variants 属性 - */ -export function useBranchOfficeUtilsAnimation() { - // ==================== 容器动画变体 ==================== - // - // 容器动画说明: - // - 控制整个子项容器的进入和离开动画 - // - 通过 staggerChildren 实现子项的波浪式出现/消失效果 - // - staggerDirection 控制子项的出现顺序(1=正向,-1=反向) - // ==================== - - const containerVariants = { - // ----- 隐藏状态 ----- - // 用途: - // 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", // 容器离开缓动函数:先慢后快,快速消失 - - // 子项依次离开的反向波浪效果 - staggerChildren: 0.03, // 离开时间隔更短(0.03 秒),显得更轻快 - staggerDirection: -1, // -1 = 反向(从最后一个到第一个) - }, - }, - - // ----- 可见状态(进入动画)----- - // 用途:当组件挂载后立即使用(animate="visible") - visible: { - opacity: 1, // 容器整体不透明 - y: 0, // 容器移动到正常位置 - - transition: { - duration: 0.3, // 容器动画时长:0.3 秒 - ease: "easeOut", // 容器缓动函数:先快后慢,营造舒适的进入体验 - - // 子项依次出现的波浪效果(进入时使用) - staggerChildren: 0.05, // 每个子项间隔 0.05 秒依次出现 - staggerDirection: 1, // 1 = 正向(从第一个到最后一个) - }, - }, - }; - - // ==================== 子项动画变体 ==================== - // - // 子项动画说明: - // - 控制单个子项的进入和离开动画 - // - 子项动画由容器的 staggerChildren 控制执行时机 - // - 每个子项独立执行相同的动画,但时间上有间隔 - // ==================== - - const itemVariants = { - // ----- 隐藏状态 ----- - // 用途: - // 1. 作为初始状态(initial="hidden") - // 2. 作为离开的目标状态(exit="hidden" 时子项会动画到此状态) - hidden: { - y: 50, // Y 轴位置:从下方 50px 处开始/离开到下方 50px - opacity: 0, // 透明度:完全透明 - }, - - // ----- 可见状态 ----- - // 用途:作为进入的目标状态(animate="visible" 时子项会动画到此状态) - visible: { - y: 0, // Y 轴位置:移动到正常位置 - opacity: 1, // 透明度:变为完全不透明 - - transition: { - duration: 0.3, // 动画时长:0.3 秒 - ease: "easeOut", // 缓动函数:快速开始,缓慢结束,更自然 - }, - }, - }; - - // ==================== 返回值 ==================== - return { - containerVariants, // 容器的动画变体,用于 motion.div 的 variants 属性 - itemVariants, // 子项的动画变体,用于每个子项 motion.div 的 variants 属性 - }; -} diff --git a/src/pages/Container/Map/components/BottomUtils/usePortUtilsAnimation.js b/src/pages/Container/Map/components/BottomUtils/usePortUtilsAnimation.js index d4d9cb2..9076595 100644 --- a/src/pages/Container/Map/components/BottomUtils/usePortUtilsAnimation.js +++ b/src/pages/Container/Map/components/BottomUtils/usePortUtilsAnimation.js @@ -13,7 +13,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; * 动画流程详解: * * 【初始显示】 - * - 使用 useBottomUtilsAnimation 的主容器动画 + * - 使用 BottomUtils 声明式主容器动画 * - 不执行父级的依次显示动画,保持静止 * * 【展开动画序列】(点击父级展开子级) diff --git a/src/pages/Container/Map/components/CenterUtils/index.js b/src/pages/Container/Map/components/CenterUtils/index.js index 013a2ae..d6e84e9 100644 --- a/src/pages/Container/Map/components/CenterUtils/index.js +++ b/src/pages/Container/Map/components/CenterUtils/index.js @@ -1,4 +1,4 @@ -import { motion } from "motion/react"; +import { AnimatePresence, motion } from "motion/react"; import { useContext, useState } from "react"; import statisticsTitleImg from "~/assets/images/map_bi/center_utils/statistics_title.png"; import guangImg from "~/assets/images/map_bi/center_utils/tabguang.png"; @@ -15,9 +15,39 @@ import { resetAllBottomUtilsCheckMittKey, resetBottomCurrentIndexMittKey, } from "~/pages/Container/Map/js/mittKey"; -import { useCenterUtilsAnimation } from "./useCenterUtilsAnimation"; import "./index.less"; +// 港区选择区从上方弹入;统计卡片保持原有缩放回弹效果。 +const containerAnimation = { + initial: { y: -120, opacity: 0, scale: 0.9 }, + animate: { + y: [-120, 40, -10, 0], + opacity: [0, 1, 1, 1], + scale: [0.9, 1.05, 0.98, 1], + transition: { duration: 0.7, times: [0, 0.5, 0.75, 1], ease: [0.34, 1.56, 0.64, 1] }, + }, + exit: { + y: [0, -50, 20, -120], + opacity: [1, 1, 0.8, 0], + scale: [1, 1.02, 0.95, 0.85], + transition: { duration: 0.5, times: [0, 0.2, 0.5, 1], ease: [0.68, -0.6, 0.32, 1.6] }, + }, +}; + +const statisticAnimation = { + initial: { scale: 0, opacity: 0 }, + animate: { + scale: [0, 1.15, 0.92, 1], + opacity: [0, 1, 1, 1], + transition: { duration: 0.7, times: [0, 0.4, 0.7, 1], ease: [0.34, 1.56, 0.64, 1] }, + }, + exit: { + scale: [1, 1.1, 0.85, 0], + opacity: [1, 1, 0.6, 0], + transition: { duration: 0.5, times: [0, 0.3, 0.6, 1], ease: [0.68, -0.6, 0.32, 1.6] }, + }, +}; + function CenterUtils(props) { const { currentPort, currentBranchOffice, pureMap, mapMethods } = useContext(Context); @@ -28,20 +58,7 @@ function CenterUtils(props) { ]; const [activeIndex, setActiveIndex] = useState(1); - const { controls: containerControls, isVisible: isContainerVisible } = useCenterUtilsAnimation( - (currentPort === "00003" && !currentBranchOffice && !pureMap), - "up-down", - ); - - const { controls: westControls, isVisible: isWestVisible } = useCenterUtilsAnimation( - ((currentPort === "00003" && !currentBranchOffice && !pureMap) && (activeIndex === 0 || activeIndex === 1)), - "scale", - ); - - const { controls: eastControls, isVisible: isEastVisible } = useCenterUtilsAnimation( - ((currentPort === "00003" && !currentBranchOffice && !pureMap) && (activeIndex === 2 || activeIndex === 1)), - "scale", - ); + const isVisible = currentPort === "00003" && !currentBranchOffice && !pureMap; const onChangeArea = (index) => { if (index === activeIndex) @@ -57,9 +74,9 @@ function CenterUtils(props) { return (
- - {isContainerVisible && ( - <> + + {isVisible && ( +
- - {isWestVisible && ( + + {(activeIndex === 0 || activeIndex === 1) && ( +
西港区
@@ -90,10 +108,12 @@ function CenterUtils(props) {
车辆:33辆
+
)} -
- - {isEastVisible && ( + + + {(activeIndex === 2 || activeIndex === 1) && ( +
东港区
@@ -101,12 +121,13 @@ function CenterUtils(props) {
车辆:33辆
+
)} -
+
- + )} - +
); } diff --git a/src/pages/Container/Map/components/CenterUtils/useCenterUtilsAnimation.js b/src/pages/Container/Map/components/CenterUtils/useCenterUtilsAnimation.js deleted file mode 100644 index 237fa7e..0000000 --- a/src/pages/Container/Map/components/CenterUtils/useCenterUtilsAnimation.js +++ /dev/null @@ -1,205 +0,0 @@ -import { useAnimation } from "motion/react"; -import { useEffect, useRef, useState } from "react"; - -/** - * CenterUtils 弹跳动画 Hook - * - * 功能说明: - * 1. 监听组件的显示/隐藏状态变化,执行炫酷的弹跳动画效果 - * 2. 支持两种动画类型:上下弹跳(up-down)和缩放弹跳(scale) - * 3. 使用位置、缩放、透明度三重动画效果,营造生动的视觉体验 - * 4. 延迟隐藏机制,确保离开动画完整播放后再移除组件 - * - * 动画参数配置: - * - up-down 模式进入动画: - * - 位置关键帧:y: [-120, 40, -10, 0](从上方120px -> 冲到下方40px -> 轻微上弹 -> 稳定) - * - 缩放关键帧: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](强弹性曲线,产生过冲和回弹) - * - 关键帧时间点:times: [0, 0.5, 0.75, 1] - * - * - up-down 模式离开动画: - * - 位置关键帧:y: [0, -50, 20, -120](正常 -> 向上弹跳50px -> 回落到20px -> 向上离开到-120px) - * - 缩放关键帧: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.2, 0.5, 1] - * - * - scale 模式进入动画: - * - 缩放关键帧:scale: [0, 1.15, 0.92, 1](0 -> 115% -> 92% -> 100%) - * - 时长:0.7s,缓动:[0.34, 1.56, 0.64, 1] - * - 关键帧时间点:times: [0, 0.4, 0.7, 1] - * - * - scale 模式离开动画: - * - 缩放关键帧:scale: [1, 1.1, 0.85, 0](100% -> 110% -> 85% -> 0) - * - 时长:0.5s,缓动:[0.68, -0.6, 0.32, 1.6] - * - 关键帧时间点:times: [0, 0.3, 0.6, 1] - * - * @param {boolean} isVisible - 控制组件的显示/隐藏 - * - true: 组件应该显示,执行进入动画 - * - false: 组件应该隐藏,执行离开动画 - * @param {string} type - 动画类型 - * - 'up-down':上下弹跳动画(默认值,从上方进入,向上方离开) - * - 'scale':缩放弹跳动画(从中心放大进入,缩小离开) - * @returns {object} 返回包含以下属性的对象: - * - controls: motion 动画控制器,绑定到 motion.div 的 animate 属性 - * - isVisible: 延迟的可见性状态,用于条件渲染(等待离开动画完成) - */ -export function useCenterUtilsAnimation(isVisible, type = "up-down") { - // ==================== 状态管理 ==================== - // - // 动画时长说明: - // - 进入动画: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(); - - // 标记是否是首次渲染 - // 作用:首次渲染时需要特殊处理,确保初始状态正确设置 - const isFirstRender = useRef(true); - - // 组件的实际显示状态 - // 作用:延迟隐藏,确保离开动画执行完成后才将组件从 DOM 中移除 - const [displayState, setDisplayState] = useState(isVisible); - - // ==================== 主题:监听可见性变化并执行弹跳动画 ==================== - // - // useEffect 的职责: - // 1. 监听 isVisible 和 type 的变化 - // 2. 根据显示/隐藏状态执行相应的弹跳动画 - // - // 场景说明: - // - 显示状态:根据 type 执行 up-down 或 scale 弹跳进入动画 - // - 隐藏状态:根据 type 执行 up-down 或 scale 弹跳离开动画 - // ==================== - - useEffect(() => { - // ==================== 显示状态:执行进入动画 ==================== - if (isVisible) { - // 步骤 1:确保组件处于显示状态 - if (!displayState) { - setDisplayState(true); - } - - // 步骤 2:根据动画类型执行进入动画 - // ---------------------------------------------------------------- - - // 类型 1:上下弹跳动画(炫酷 bounceInDown 效果) - if (type === "up-down") { - if (isFirstRender.current) { - // === 首次渲染:炫酷弹跳进入 === - controls.start({ - y: [-120, 40, -10, 0], // 四个关键帧:上方120px -> 超出下方40px(大弹跳)-> 轻微上弹 -> 稳定 - opacity: [0, 1, 1, 1], // 快速淡入 - scale: [0.9, 1.05, 0.98, 1], // 缩放:缩小 -> 轻微放大 -> 轻微缩小 -> 正常 - transition: { - duration: 0.7, // 总动画时长:0.7 秒 - times: [0, 0.5, 0.75, 1], // 时间点分布:快速下落,多次回弹 - ease: [0.34, 1.56, 0.64, 1], // 强弹性贝塞尔曲线,产生明显的回弹效果 - }, - }).then(() => { - isFirstRender.current = false; - }); - } - else { - // === 非首次渲染:炫酷弹跳进入 === - controls.start({ - y: [-120, 40, -10, 0], - opacity: [0, 1, 1, 1], - scale: [0.9, 1.05, 0.98, 1], - transition: { - duration: 0.7, - times: [0, 0.5, 0.75, 1], - ease: [0.34, 1.56, 0.64, 1], - }, - }); - } - } - // ---------------------------------------------------------------- - - // 类型 2:缩放弹跳动画(炫酷 bounceIn 效果) - else if (type === "scale") { - if (isFirstRender.current) { - // === 首次渲染:炫酷缩放弹跳 === - controls.start({ - scale: [0, 1.15, 0.92, 1], // 四个关键帧:0 -> 超出115% -> 缩小到92% -> 正常 - opacity: [0, 1, 1, 1], - transition: { - duration: 0.7, - times: [0, 0.4, 0.7, 1], // 时间点分布 - ease: [0.34, 1.56, 0.64, 1], // 强弹性曲线 - }, - }).then(() => { - isFirstRender.current = false; - }); - } - else { - // === 非首次渲染:炫酷缩放弹跳 === - controls.start({ - scale: [0, 1.15, 0.92, 1], - opacity: [0, 1, 1, 1], - transition: { - duration: 0.7, - times: [0, 0.4, 0.7, 1], - ease: [0.34, 1.56, 0.64, 1], - }, - }); - } - } - } - - // ==================== 隐藏状态:执行离开动画 ==================== - else { - // 步骤 1:根据动画类型执行离开动画 - // ---------------------------------------------------------------- - - // 类型 1:上下弹跳动画(炫酷 bounceOutUp 效果) - if (type === "up-down") { - controls.start({ - y: [0, -50, 20, -120], // 四个关键帧:正常 -> 超出上方50px -> 回落 -> 向上离开 - opacity: [1, 1, 0.8, 0], // 保持可见再淡出 - scale: [1, 1.02, 0.95, 0.85], // 缩放:轻微放大 -> 轻微缩小 - transition: { - duration: 0.5, // 总动画时长:0.5 秒 - times: [0, 0.2, 0.5, 1], // 时间点分布:快速弹起,缓慢落下 - ease: [0.68, -0.6, 0.32, 1.6], // 超弹性曲线,产生夸张的回弹效果 - }, - }).then(() => { - setDisplayState(false); - }); - } - // ---------------------------------------------------------------- - - // 类型 2:缩放弹跳动画(炫酷 bounceOut 效果) - else if (type === "scale") { - controls.start({ - scale: [1, 1.1, 0.85, 0], // 四个关键帧:正常 -> 放大到110% -> 缩小到85% -> 消失 - opacity: [1, 1, 0.6, 0], - transition: { - duration: 0.5, - times: [0, 0.3, 0.6, 1], - ease: [0.68, -0.6, 0.32, 1.6], // 超弹性曲线 - }, - }).then(() => { - setDisplayState(false); - }); - } - } - }, [isVisible, type, controls, displayState]); - - // ==================== 返回值 ==================== - return { - controls, // motion 动画控制器,绑定到 motion.div 的 animate 属性 - isVisible: displayState, // 延迟的可见性状态,用于条件渲染(等待离开动画完成) - }; -} diff --git a/src/pages/Container/Map/components/Content/index.js b/src/pages/Container/Map/components/Content/index.js index 82cce10..8fc07c1 100644 --- a/src/pages/Container/Map/components/Content/index.js +++ b/src/pages/Container/Map/components/Content/index.js @@ -1,5 +1,5 @@ -import { motion } from "motion/react"; -import { useContext } from "react"; +import { AnimatePresence, motion } from "motion/react"; +import { useContext, useState } from "react"; import collapseMenuImg1 from "~/assets/images/map_bi/content/collapse_menu1.png"; import collapseMenuImg2 from "~/assets/images/map_bi/content/collapse_menu2.png"; import collapseMenuBg1 from "~/assets/images/map_bi/content/collapse_menu_bg1.png"; @@ -26,219 +26,146 @@ import PortWeiXian from "~/pages/Container/Map/components/Content/port/WeiXian"; import PortXiaoFang from "~/pages/Container/Map/components/Content/port/XiaoFang"; import PortZhongDian from "~/pages/Container/Map/components/Content/port/ZhongDian"; import { Context } from "~/pages/Container/Map/js/context"; -import { useContentAnimation } from "./useContentAnimation"; import "./index.less"; +const indexAnimation = { + initial: { scale: 0.8, opacity: 0 }, + animate: { scale: 1, opacity: 1, transition: { duration: 0.5, ease: "easeOut" } }, + exit: { scale: 0.8, opacity: 0, transition: { duration: 0.3, ease: "easeIn" } }, +}; + +const panelAnimation = side => ({ + initial: { x: side === "left" ? -300 : 300, opacity: 0 }, + animate: { x: 0, opacity: 1, transition: { duration: 0.5, ease: "easeOut" } }, + exit: { x: side === "left" ? -300 : 300, opacity: 0, transition: { duration: 0.3, ease: "easeIn" } }, +}); + function Content(props) { const { currentPort, currentBranchOffice, pureMap, bottomUtilsCurrentIndex } = useContext(Context); + const [isLeftCollapsed, setIsLeftCollapsed] = useState(false); + const [isRightCollapsed, setIsRightCollapsed] = useState(false); - const { - controls: indexInfoControls, - isVisible: isIndexInfoVisible, - } = useContentAnimation({ - currentContent: { currentPort, currentBranchOffice }, - type: "index", - }); - - const { - controls: leftControls, - displayedContent: leftDisplayedContent, - isVisible: isLeftVisible, - isCollapsed: isLeftCollapsed, - handleCollapse: handleLeftCollapse, - menuControls: leftMenuControls, - } = useContentAnimation({ - currentContent: { currentPort, currentBranchOffice, bottomUtilsCurrentIndex }, - side: "left", - isPureMap: pureMap, - type: "panel", - }); - - const { - controls: rightControls, - displayedContent: rightDisplayedContent, - isVisible: isRightVisible, - isCollapsed: isRightCollapsed, - handleCollapse: handleRightCollapse, - menuControls: rightMenuControls, - } = useContentAnimation({ - currentContent: { currentBranchOffice, bottomUtilsCurrentIndex }, - side: "right", - isPureMap: pureMap, - type: "panel", - }); + const bottomUtilsCurrentType = currentBranchOffice + ? branchOfficeUtilsList[bottomUtilsCurrentIndex]?.type || "" + : portUtilsList[bottomUtilsCurrentIndex]?.type || ""; + const contentKey = `${currentPort}_${currentBranchOffice}_${bottomUtilsCurrentIndex}`; + const isPortContent = currentPort === "00003" && !currentBranchOffice; const renderPortContent = () => { - const bottomUtilsCurrentType = (leftDisplayedContent.bottomUtilsCurrentIndex !== -1 && portUtilsList[leftDisplayedContent.bottomUtilsCurrentIndex]) - ? portUtilsList[leftDisplayedContent.bottomUtilsCurrentIndex].type - : ""; - if (bottomUtilsCurrentType === "" || bottomUtilsCurrentType === "camera") - return ; - if (bottomUtilsCurrentType === "door") - return ; - if (bottomUtilsCurrentType === "fire") - return ; - if (bottomUtilsCurrentType === "danger") - return ; - if (bottomUtilsCurrentType === "weather") - return ; - if (bottomUtilsCurrentType === "people") - return ; - if (bottomUtilsCurrentType === "project") - return ; - if (bottomUtilsCurrentType === "closedArea") - return ; + const contentMap = { + "": PortIndex, + "camera": PortIndex, + "door": PortMenJin, + "fire": PortXiaoFang, + "danger": PortWeiXian, + "weather": PortQiXiang, + "people": PortRenYuan, + "project": PortZhongDian, + "closedArea": PortFengBi, + }; + const Component = contentMap[bottomUtilsCurrentType]; + return Component ? : null; }; const renderBranchOfficeContentLeft = () => { - const bottomUtilsCurrentType = (leftDisplayedContent.bottomUtilsCurrentIndex !== -1 && branchOfficeUtilsList[leftDisplayedContent.bottomUtilsCurrentIndex]) - ? branchOfficeUtilsList[leftDisplayedContent.bottomUtilsCurrentIndex].type - : ""; - if (bottomUtilsCurrentType === "") - return ; - if (bottomUtilsCurrentType === "danger") - return ; - if (bottomUtilsCurrentType === "door") - return ; - if (bottomUtilsCurrentType === "fire") - return ; - if (bottomUtilsCurrentType === "people") - return ; - if (bottomUtilsCurrentType === "weather") - return ; - if (bottomUtilsCurrentType === "project") - return ; - if (bottomUtilsCurrentType === "closedArea") - return ; - if (bottomUtilsCurrentType === "camera") - return ; + const contentMap = { + "": BranchOfficeIndexLeft, + "danger": BranchWeiXian, + "door": BranchMenJin, + "fire": BranchXiaoFang, + "people": BranchRenYuan, + "weather": BranchQiXiang, + "project": BranchZhongDian, + "closedArea": BranchFengBi, + "camera": BranchCamera, + }; + const Component = contentMap[bottomUtilsCurrentType]; + return Component ? : null; }; - const renderBranchOfficeContentRight = () => { - const bottomUtilsCurrentType = (rightDisplayedContent.bottomUtilsCurrentIndex !== -1 && branchOfficeUtilsList[rightDisplayedContent.bottomUtilsCurrentIndex]) - ? branchOfficeUtilsList[rightDisplayedContent.bottomUtilsCurrentIndex].type - : ""; - if (bottomUtilsCurrentType === "") - return ; - }; + const renderCollapseButton = ({ side, isCollapsed, setIsCollapsed }) => { + const isPort = isPortContent; + const collapsedX = side === "left" ? -465 : 465; + const containerRotation = !isPort && side === "right" ? 180 : 0; + const imageRotation = isPort + ? (isCollapsed ? 180 : 0) + : (isCollapsed ? 0 : 180); - const renderContent = () => { return ( - <> - {isIndexInfoVisible && ( - - - - )} - {isLeftVisible && ( - - {(leftDisplayedContent.currentPort === "00003" && !leftDisplayedContent.currentBranchOffice) && renderPortContent()} - {leftDisplayedContent.currentBranchOffice && renderBranchOfficeContentLeft()} - - )} - {isRightVisible && ( - - {rightDisplayedContent.currentBranchOffice && renderBranchOfficeContentRight()} - - )} - + setIsCollapsed(value => !value)} + > + + ); }; - const renderCollapseMenu = () => { - if (pureMap) - return null; - - if (currentPort === "00003" && !currentBranchOffice) { - return ( - - - - ); - } - - if (currentBranchOffice) { - const bottomUtilsCurrentType = bottomUtilsCurrentIndex !== -1 ? branchOfficeUtilsList[bottomUtilsCurrentIndex].type : ""; - - return ( - <> - {bottomUtilsCurrentType !== "camera" && ( - - - - )} - {bottomUtilsCurrentIndex === -1 && ( - - - - )} - - ); - } - }; - return (
- {renderContent()} - {renderCollapseMenu()} + + {!currentPort && !currentBranchOffice && ( + + + + )} + + + + {!pureMap && !isLeftCollapsed && (isPortContent || currentBranchOffice) && ( + + {isPortContent ? renderPortContent() : renderBranchOfficeContentLeft()} + + )} + + + + {!pureMap && !isRightCollapsed && currentBranchOffice && ( + + {bottomUtilsCurrentType === "" && } + + )} + + + {!pureMap && isPortContent && renderCollapseButton({ + side: "left", + isCollapsed: isLeftCollapsed, + setIsCollapsed: setIsLeftCollapsed, + })} + + {!pureMap && currentBranchOffice && ( + <> + {bottomUtilsCurrentType !== "camera" && renderCollapseButton({ + side: "left", + isCollapsed: isLeftCollapsed, + setIsCollapsed: setIsLeftCollapsed, + })} + {bottomUtilsCurrentIndex === -1 && renderCollapseButton({ + side: "right", + isCollapsed: isRightCollapsed, + setIsCollapsed: setIsRightCollapsed, + })} + + )}
); } diff --git a/src/pages/Container/Map/components/Content/useContentAnimation.js b/src/pages/Container/Map/components/Content/useContentAnimation.js deleted file mode 100644 index 9e0ac3a..0000000 --- a/src/pages/Container/Map/components/Content/useContentAnimation.js +++ /dev/null @@ -1,458 +0,0 @@ -import { useAnimation } from "motion/react"; -import { useEffect, useRef, useState } from "react"; - -/** - * 内容动画 Hook(统一处理 IndexInfo 和面板动画) - * - * 功能说明: - * 1. 通过 type 参数区分动画类型:"index"(IndexInfo 缩放动画)或 "panel"(面板滑动动画) - * 2. IndexInfo 类型:管理首页内容的显示/隐藏,使用缩放动画 - * 3. Panel 类型:管理内容面板的显示/隐藏,使用滑动动画,支持折叠/展开 - * - * @param {object} params - 参数对象 - * @param {object} params.currentContent - 当前要显示的内容状态(包含 currentPort、currentBranchOffice、bottomUtilsCurrentIndex 等) - * @param {string} params.side - 'left' 或 'right',控制动画方向(仅 panel 类型) - * @param {boolean} params.isPureMap - 是否为纯地图模式(仅 panel 类型) - * @param {string} params.type - 动画类型:"index" 或 "panel"(默认 "panel") - * @returns {object} 返回包含以下属性的对象: - * - controls: motion 动画控制器 - * - displayedContent: 延迟显示的内容状态(仅 panel 类型) - * - isVisible: 元素可见性状态 - * - isCollapsed: 折叠状态(仅 panel 类型) - * - handleCollapse: 处理折叠/展开的函数(仅 panel 类型) - */ -export function useContentAnimation({ - currentContent, - side = "left", - isPureMap = false, - type = "panel", -}) { - // ==================== 状态管理 ==================== - // - // 动画时长说明: - // - 进入动画:0.5s,较慢,营造舒适的进入体验 - // - 离开动画:0.3s,较快,让用户感觉到响应迅速 - // - // 缓动函数说明: - // - easeOut:进入时使用,先快后慢,更自然 - // - easeIn:离开时使用,先慢后快,快速消失 - // ==================== - - // motion 动画控制器,用于控制内容面板的动画 - const controls = useAnimation(); - - // 菜单按钮的动画控制器,用于控制折叠按钮的动画 - const menuControls = useAnimation(); - - // 元素可见性状态 - // false: 元素被隐藏(折叠或纯地图模式) - // true: 元素可见(非折叠、非纯地图模式) - const [isVisible, setIsVisible] = useState(true); - - // 标记当前是否正在执行动画(防止动画冲突) - const isAnimating = useRef(false); - - // 标记是否是首次渲染(首次渲染不执行离开动画,直接进入) - const isFirstRender = useRef(true); - - // 折叠状态(仅 panel 类型使用) - // true: 面板被折叠,点击按钮可展开 - // false: 面板展开,点击按钮可折叠 - const [isCollapsed, setIsCollapsed] = useState(false); - - // 延迟显示的内容状态(仅 panel 类型使用) - // 作用:在内容切换时,先显示旧内容,动画完成后再更新为新内容 - // 这样可以实现旧内容离开 -> 新内容进入的平滑过渡 - const [displayedContent, setDisplayedContent] = useState(type === "index" ? {} : currentContent); - - // 标记是否正在手动处理折叠/展开(防止 useEffect 干预) - // 当手动点击折叠/展开按钮时,设置此标记为 true,阻止 useEffect 中的自动动画逻辑 - const isManualCollapseAnimating = useRef(false); - - // ==================== IndexInfo 类型的缩放动画逻辑 ==================== - - useEffect(() => { - // 仅处理 index 类型 - if (type !== "index") { - return; - } - - // 判断是否应该显示 IndexInfo(没有选择任何港口或分公司时) - const shouldShowIndexInfo = !currentContent.currentPort && !currentContent.currentBranchOffice; - - // ==================== 场景 1:应该显示 IndexInfo ==================== - if (shouldShowIndexInfo) { - // ----- 情况 A:首次渲染 ----- - if (isFirstRender.current) { - // 1. 设置元素到初始缩小状态 - controls.set({ - scale: 0.8, // 缩小到 80% - opacity: 0, // 透明 - }); - - // 2. 显示元素 - setIsVisible(true); - - // 3. 执行放大进入动画(从小到大缩放) - // 使用 requestAnimationFrame 确保在下一帧执行动画,避免在当前帧被覆盖 - requestAnimationFrame(() => { - controls.start({ - scale: 1, // 放大到正常大小 - opacity: 1, // 淡入 - transition: { duration: 0.5, ease: "easeOut" }, - }); - }); - - // 4. 标记首次渲染完成 - isFirstRender.current = false; - } - - // ----- 情况 B:从其他内容切换回来:放大进入(带延迟,等待其他内容离开动画完成) ----- - else if (!isVisible && !isAnimating.current) { - // 标记开始执行动画(防止动画冲突) - isAnimating.current = true; - - // 1. 显示元素 - setIsVisible(true); - - // 2. 执行放大进入动画 - // 使用双重 requestAnimationFrame: - // - 第一层:确保状态更新已生效 - // - 第二层:确保 set() 操作完成后再执行 start() - requestAnimationFrame(() => { - // 设置元素到初始缩小状态 - controls.set({ - scale: 0.8, - opacity: 0, - }); - - // 然后触发放大进入动画(带 0.3s 延迟,等待其他内容离开) - // 延迟时间匹配其他内容的离开动画时长(0.3s),确保视觉上的连贯性 - requestAnimationFrame(() => { - controls.start({ - scale: 1, - opacity: 1, - transition: { duration: 0.5, ease: "easeOut", delay: 0.3 }, - }).then(() => { - // 动画完成标记 - isAnimating.current = false; - }); - }); - }); - } - } - - // ==================== 场景 2:不应该显示 IndexInfo(选择了港口或分公司)==================== - else if (isVisible && !isAnimating.current) { - // 标记开始执行动画(防止动画冲突) - isAnimating.current = true; - - // 执行缩小离开动画 - controls.start({ - scale: 0.8, // 缩小到 80% - opacity: 0, // 淡出 - transition: { duration: 0.3, ease: "easeIn" }, - }).then(() => { - // 动画完成后: - // 1. 隐藏元素 - setIsVisible(false); - - // 2. 标记动画完成 - isAnimating.current = false; - }); - } - }, [currentContent.currentPort, currentContent.currentBranchOffice, controls, isVisible, type]); - - // ==================== Panel 类型的滑动动画逻辑 ==================== - - // 辅助函数:计算动画的初始位置 - // @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) { - // ==================== 展开逻辑 ==================== - // 标记开始手动处理折叠/展开动画(阻止 useEffect 干预) - isManualCollapseAnimating.current = true; - - // 1. 先同步显示内容(确保显示最新的内容) - setDisplayedContent(currentContent); - - // 2. 更新折叠状态 - setIsCollapsed(false); - - // 3. 显示元素 - setIsVisible(true); - - // 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({ - x: getInitialX(), - opacity: 0, - }); - - requestAnimationFrame(() => { - // 然后执行进入动画 - controls.start({ - x: 0, // 移动到正常位置 - opacity: 1, // 淡入 - transition: { duration: 0.5, ease: "easeOut" }, - }).then(() => { - // 动画完成后清除手动处理标记,允许 useEffect 再次响应状态变化 - isManualCollapseAnimating.current = false; - }); - }); - }); - } - else { - // ==================== 折叠逻辑 ==================== - // 标记开始手动处理折叠/展开动画(阻止 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. 隐藏元素(从 DOM 中移除或设置 display: none) - setIsVisible(false); - - // 2. 更新折叠状态为 true - setIsCollapsed(true); - - // 3. 清除手动处理标记,允许 useEffect 再次响应状态变化 - isManualCollapseAnimating.current = false; - }); - } - }; - - // ==================== 主题:监听状态变化并执行动画 ==================== - // - // useEffect 的职责: - // 1. 监听 currentContent、isPureMap、isCollapsed 等状态变化 - // 2. 根据不同场景自动执行相应的动画 - // 3. 不处理手动折叠/展开动画(由 handleCollapse 处理) - // - // 场景优先级(从高到低): - // 1. 纯地图模式:最高优先级,立即隐藏所有面板 - // 2. 折叠状态:静默更新数据,不执行动画 - // 3. 正常模式:根据内容变化、可见性等执行相应动画 - // ==================== - - useEffect(() => { - // 仅处理 panel 类型 - if (type !== "panel") { - return; - } - - // 如果正在手动处理折叠/展开动画,不执行 useEffect 逻辑 - // 这一步很关键,避免 handleCollapse 和 useEffect 之间的动画冲突 - if (isManualCollapseAnimating.current) { - return; - } - - // ==================== 场景 1:纯地图模式(最高优先级)==================== - if (isPureMap) { - // 如果元素当前可见,执行离开动画并隐藏 - if (isVisible) { - controls.start({ - x: getInitialX(), // 滑到侧边 - opacity: 0, // 淡出 - transition: { duration: 0.3, ease: "easeIn" }, - }).then(() => { - // 动画完成后隐藏元素 - setIsVisible(false); - }); - } - // 纯地图模式下,不执行任何后续逻辑 - return; - } - - // ==================== 场景 2:折叠状态 ==================== - if (isCollapsed) { - // 检查内容是否发生变化 - const contentChanged = Object.keys(currentContent).some( - key => displayedContent[key] !== currentContent[key], - ); - - // 静默更新内容(不执行动画,保持数据同步) - // 目的:虽然面板是折叠的,但数据要始终保持最新 - // 这样展开时显示的就是最新内容,不需要额外加载 - if (contentChanged) { - setDisplayedContent(currentContent); - } - - // 确保元素保持隐藏(防止异常情况导致的意外显示) - if (isVisible) { - setIsVisible(false); - } - - // 折叠状态下,不执行任何动画逻辑 - return; - } - - // ==================== 场景 3:正常模式(非折叠、非纯地图)==================== - // 正常模式下,面板应该可见并根据内容变化执行动画 - - // 检查内容是否真的变化了 - const contentChanged = Object.keys(currentContent).some( - key => displayedContent[key] !== currentContent[key], - ); - - // ----- 情况 A:首次渲染 ----- - if (isFirstRender.current) { - // 1. 设置元素到初始隐藏位置(侧边外部) - controls.set({ - x: getInitialX(), - opacity: 0, - }); - - // 2. 设置菜单按钮到初始位置(展开状态,在内容区旁边) - // 菜单按钮不执行进入动画,直接显示在最终位置 - menuControls.set({ - x: 0, - }); - - // 3. 立即更新显示的内容 - setDisplayedContent(currentContent); - - // 4. 执行滑入动画(从侧边滑入到正常位置) - controls.start({ - x: 0, // 移动到正常位置 - opacity: 1, // 淡入 - transition: { duration: 0.5, ease: "easeOut" }, - }); - - // 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; - - // 1. 先执行离开动画(旧内容离开) - controls.start({ - x: getInitialX(), // 滑到侧边 - opacity: 0, // 淡出 - transition: { duration: 0.3, ease: "easeIn" }, - }).then(() => { - // 离开动画完成后: - - // 2. 更新显示的内容为新内容 - setDisplayedContent(currentContent); - - // 3. 标记动画完成 - isAnimating.current = false; - - // 4. 执行进入动画(新内容进入) - controls.start({ - x: 0, // 从侧边滑入到正常位置 - opacity: 1, // 淡入 - transition: { duration: 0.5, ease: "easeOut" }, - }); - }); - } - - // ----- 情况 C:从隐藏状态切换回来(退出纯地图模式)----- - // 注意:展开操作由 handleCollapse 处理,不在这里处理 - // 这里处理的是退出纯地图模式后的自动显示 - else if (!isVisible && !isCollapsed) { - // 1. 显示元素 - setIsVisible(true); - - // 2. 更新显示的内容 - setDisplayedContent(currentContent); - - // 3. 执行进入动画 - // 使用双重 requestAnimationFrame 确保动画正确执行 - requestAnimationFrame(() => { - // 设置元素到初始隐藏位置 - controls.set({ - x: getInitialX(), - opacity: 0, - }); - - // 然后触发进入动画 - requestAnimationFrame(() => { - controls.start({ - x: 0, // 滑入到正常位置 - opacity: 1, // 淡入 - transition: { duration: 0.5, ease: "easeOut" }, - }); - }); - }); - } - }, [currentContent, isCollapsed, isPureMap, controls, displayedContent, side, isVisible, type]); - - // ==================== 返回值 ==================== - // - // 根据类型返回不同的属性: - // - index 类型:只返回 controls 和 isVisible(简单动画) - // - panel 类型:返回完整的控制属性(包括折叠功能) - // ==================== - - // 返回值根据类型不同而不同 - if (type === "index") { - return { - controls, // motion 动画控制器,用于 IndexInfo 的缩放动画 - isVisible, // 元素可见性状态,控制 IndexInfo 的显示/隐藏 - }; - } - - return { - controls, // motion 动画控制器,用于内容面板的滑动动画 - displayedContent, // 延迟显示的内容(用于动画过渡) - isVisible, // 元素可见性状态,控制内容面板的显示/隐藏 - isCollapsed, // 折叠状态,标识当前面板是否被折叠 - handleCollapse, // 折叠/展开处理函数,响应按钮点击事件 - menuControls, // 菜单按钮的动画控制器,用于控制折叠按钮的位置动画 - }; -} diff --git a/src/pages/Container/Map/components/Header/index.js b/src/pages/Container/Map/components/Header/index.js index 6b4548c..f1eef2a 100644 --- a/src/pages/Container/Map/components/Header/index.js +++ b/src/pages/Container/Map/components/Header/index.js @@ -1,4 +1,4 @@ -import { motion } from "motion/react"; +import { AnimatePresence, motion } from "motion/react"; import { useContext } from "react"; import backImg1 from "~/assets/images/map_bi/back1.png"; import backImg2 from "~/assets/images/map_bi/back2.png"; @@ -16,13 +16,19 @@ import { resetAllBottomUtilsCheckMittKey, resetBottomCurrentIndexMittKey, } from "~/pages/Container/Map/js/mittKey"; -import { useHeaderAnimation } from "./useHeaderAnimation"; import "./index.less"; +// 标题切换时先让旧标题离开,再让新标题进入,保留原来的上下滑动和淡入淡出效果。 +const headerAnimation = { + initial: { y: -100, opacity: 0 }, + animate: { y: 0, opacity: 1, transition: { duration: 0.5, ease: "easeOut" } }, + exit: { y: -100, opacity: 0, transition: { duration: 0.3, ease: "easeIn" } }, +}; + function Header(props) { const { currentPort, currentBranchOffice, mapMethods, area } = useContext(Context); - const { controls, displayedTitle } = useHeaderAnimation(props.headerTitle); + const isPortTitle = props.headerTitle === "秦港股份安全监管平台"; const onBack = () => { sessionStorage.removeItem("mapCurrentBranchOfficeId"); @@ -73,23 +79,25 @@ function Header(props) { return (
- - {(displayedTitle === "秦港股份安全监管平台") && ( -
- )} - {displayedTitle !== "秦港股份安全监管平台" && ( -
- -
返回
-
- )} -
{displayedTitle}
-
- + + + {isPortTitle + ?
+ : ( +
+ +
返回
+
+ )} +
{props.headerTitle}
+
+ +
); } diff --git a/src/pages/Container/Map/components/Header/useHeaderAnimation.js b/src/pages/Container/Map/components/Header/useHeaderAnimation.js deleted file mode 100644 index 40c9fe2..0000000 --- a/src/pages/Container/Map/components/Header/useHeaderAnimation.js +++ /dev/null @@ -1,129 +0,0 @@ -import { useAnimation } from "motion/react"; -import { useEffect, useRef, useState } from "react"; - -/** - * Header 组件动画 Hook - * - * 功能说明: - * 1. 监听标题内容变化,执行上下方向的动画效果 - * 2. 首次加载时从上方滑入,后续切换时旧标题向上离开 + 新标题向下进入 - * 3. 使用延迟显示模式,确保动画过渡完整可见 - * - * 动画参数配置: - * - 进入动画: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 动画控制器,绑定到 motion.header 的 animate 属性 - * - displayedTitle: 延迟显示的标题,用于在动画过渡期间保持视觉连续性 - */ -export function useHeaderAnimation(currentTitle) { - // ==================== 状态管理 ==================== - // - // 动画时长说明: - // - 进入动画:0.5s,较慢,营造舒适的进入体验 - // - 离开动画:0.3s,较快,让用户感觉到响应迅速 - // - // 缓动函数说明: - // - easeOut:进入时使用,先快后慢,更自然 - // - easeIn:离开时使用,先慢后快,快速消失 - // ==================== - - // motion 动画控制器,用于控制 motion.header 的动画 - const controls = useAnimation(); - - // 标记是否是首次渲染 - // 作用:首次渲染时需要特殊处理,确保初始状态正确设置 - const isFirstRender = useRef(true); - - // 延迟显示的标题状态 - // 作用:在标题切换时,先显示旧标题,动画完成后再更新为新标题 - // 这样确保离开动画显示的是旧标题,进入动画显示的是新标题 - const [displayedTitle, setDisplayedTitle] = useState(currentTitle); - - // ==================== 主题:监听标题变化并执行动画 ==================== - // - // useEffect 的职责: - // 1. 监听 currentTitle 的变化 - // 2. 根据首次渲染或标题变化执行相应的动画 - // - // 场景说明: - // - 首次渲染:初始化并执行进入动画 - // - 标题变化:旧标题离开 -> 新标题进入 - // ==================== - - useEffect(() => { - // 检查标题是否发生变化 - const titleChanged = displayedTitle !== currentTitle; - - // ==================== 场景 1:首次渲染 ==================== - if (isFirstRender.current) { - // 1. 设置元素到初始隐藏位置(上方外部) - // controls.set() 是立即设置,不会触发动画过渡 - controls.set({ - y: -100, // Y 轴位置:从上方 100px 处开始 - opacity: 0, // 透明度:完全透明 - }); - - // 2. 立即更新显示的标题 - // 因为首次渲染不需要保留旧标题,直接设置为传入的标题 - setDisplayedTitle(currentTitle); - - // 3. 执行滑入动画(从上方滑入到正常位置) - controls.start({ - y: 0, // 移动到正常位置 - opacity: 1, // 淡入 - transition: { - duration: 0.5, // 动画时长:0.5 秒 - ease: "easeOut", // 缓动函数:快速开始,缓慢结束,营造舒适的进入体验 - }, - }); - - // 4. 标记首次渲染完成 - isFirstRender.current = false; - } - - // ==================== 场景 2:标题变化 ==================== - else if (titleChanged) { - // 1. 先执行离开动画(旧标题向上离开) - controls.start({ - y: -100, // 向上移动到外部 - opacity: 0, // 淡出 - transition: { - duration: 0.3, // 动画时长:0.3 秒(比进入动画短,显得更轻快) - ease: "easeIn", // 缓动函数:缓慢开始,快速结束,让用户感觉到响应迅速 - }, - }).then(() => { - // 离开动画完成后: - - // 2. 更新显示的标题为新标题 - // 此时旧标题已经完全离开视图,更新标题用户看不见 - setDisplayedTitle(currentTitle); - - // 3. 执行进入动画(新标题从上方进入) - controls.start({ - y: 0, // 从上方移动到正常位置 - opacity: 1, // 淡入 - 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/index.js b/src/pages/Container/Map/components/RightUtils/index.js index 9318946..4841be5 100644 --- a/src/pages/Container/Map/components/RightUtils/index.js +++ b/src/pages/Container/Map/components/RightUtils/index.js @@ -1,7 +1,7 @@ import { message } from "antd"; import { produce } from "immer"; -import { motion } from "motion/react"; -import { useContext, useEffect, useState } from "react"; +import { AnimatePresence, motion } from "motion/react"; +import { useContext, useEffect, useRef, useState } from "react"; import tooltipImg2 from "~/assets/images/map_bi/right_utils/branch_office/bg11.png"; import buttonBg from "~/assets/images/map_bi/right_utils/branch_office/button.png"; import tooltipImg1 from "~/assets/images/map_bi/right_utils/port/tooltip.png"; @@ -14,33 +14,40 @@ import { deletePeoplePositionPointMittKey, resetAllBottomUtilsCheckMittKey, } from "~/pages/Container/Map/js/mittKey"; -import { useChildMenuAnimation } from "./useChildMenuAnimation"; -import { useRightUtilsAnimation } from "./useRightUtilsAnimation"; import { utilsList } from "./utilsList"; import "./index.less"; +const rightUtilsAnimation = { + initial: { x: 100, opacity: 0 }, + animate: { x: 0, opacity: 1, transition: { duration: 0.5, ease: "easeOut" } }, + exit: { x: 100, opacity: 0, transition: { duration: 0.3, ease: "easeIn" } }, +}; + +const childMenuAnimation = { + visible: { + y: [80, -15, 5, 0], + opacity: [0, 1, 1, 1], + scale: [0.9, 1.05, 0.98, 1], + transition: { duration: 0.7, times: [0, 0.5, 0.75, 1], ease: [0.34, 1.56, 0.64, 1] }, + }, + hidden: { + y: [0, -20, 10, 100], + opacity: [1, 1, 0.8, 0], + scale: [1, 1.02, 0.95, 0.85], + transition: { duration: 0.5, times: [0, 0.2, 0.5, 1], ease: [0.68, -0.6, 0.32, 1.6] }, + }, +}; + function RightUtils(props) { const { currentPort, mapMethods, pureMap, currentBranchOffice, bottomUtilsCurrentIndex } = useContext(Context); const [list, setList] = useState(utilsList); const [isShowChildLevel, setIsShowChildLevel] = useState(true); - - const { - controls: rightUtilsControls, - displayedMode: rightUtilsDisplayedMode, - isVisible: rightUtilsIsVisible, - } = useRightUtilsAnimation( - currentPort && !currentBranchOffice, - currentBranchOffice && bottomUtilsCurrentIndex !== -1, - ); - - const { - controls: childMenuControls, - isAnimating: isChildMenuAnimating, - tryStartAnimation: tryStartChildMenuAnimation, - } = useChildMenuAnimation( - currentBranchOffice && bottomUtilsCurrentIndex !== -1 && isShowChildLevel, - ); + const childMenuAnimationLockRef = useRef(true); + const [isChildMenuAnimating, setIsChildMenuAnimating] = useState(true); + const rightUtilsMode = currentPort && !currentBranchOffice + ? "port" + : currentBranchOffice && bottomUtilsCurrentIndex !== -1 ? "branchOffice" : null; useEffect(() => { if (bottomUtilsCurrentIndex === -1) @@ -146,7 +153,7 @@ function RightUtils(props) { }; const renderPortUtils = () => { - if (rightUtilsDisplayedMode === "port") { + if (rightUtilsMode === "port") { return ( <> {list.map((item, index) => ( @@ -166,10 +173,23 @@ function RightUtils(props) { }; const renderBranchOfficeUtils = () => { - if (rightUtilsDisplayedMode === "branchOffice") { + if (rightUtilsMode === "branchOffice") { return ( <> - + { + childMenuAnimationLockRef.current = true; + setIsChildMenuAnimating(true); + }} + onAnimationComplete={() => { + childMenuAnimationLockRef.current = false; + setIsChildMenuAnimating(false); + }} + > {list.map((item, index) => (
{ - if (!tryStartChildMenuAnimation()) + if (childMenuAnimationLockRef.current) return; + // 点击时同步占用锁,避免等待下一次渲染期间发生连续点击。 + childMenuAnimationLockRef.current = true; + setIsChildMenuAnimating(true); setIsShowChildLevel(visible => !visible); }} > @@ -200,17 +223,18 @@ function RightUtils(props) { return (
- - {rightUtilsIsVisible && ( - <> + + {rightUtilsMode && ( + {renderPortUtils()} {renderBranchOfficeUtils()} - + )} - +
); } diff --git a/src/pages/Container/Map/components/RightUtils/useChildMenuAnimation.js b/src/pages/Container/Map/components/RightUtils/useChildMenuAnimation.js deleted file mode 100644 index b56848c..0000000 --- a/src/pages/Container/Map/components/RightUtils/useChildMenuAnimation.js +++ /dev/null @@ -1,230 +0,0 @@ -import { useAnimation } from "motion/react"; -import { useEffect, useRef, useState } from "react"; - -/** - * RightUtils 子菜单动画 Hook - * - * 功能说明: - * 1. 监听子菜单的展开/收起状态变化,执行炫酷的弹跳动画效果 - * 2. 首次加载时从下向上弹跳进入,收起时向上弹跳后向下离开 - * 3. 使用位置、缩放、透明度三重动画效果,营造生动的视觉体验 - * - * 动画参数配置: - * - 展开动画(进入): - * - 位置关键帧: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](强弹性曲线,产生过冲和回弹) - * - * - 收起动画(离开): - * - 位置关键帧: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: 子菜单应该展开,执行展开动画 - * - false: 子菜单应该收起,执行收起动画 - * @returns {object} 返回包含以下属性的对象: - * - 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(); - - // 标记子菜单是否已经执行过展开动画 - // 作用:防止重复执行展开动画,只在首次显示时执行 - const isExpanded = useRef(false); - - // 标记是否是首次渲染 - // 作用:首次渲染时需要特殊处理,确保初始状态正确设置 - const isFirstRender = useRef(true); - - // 使用 ref 同步锁定点击,避免 React 状态尚未更新时连续点击穿透;state 负责更新按钮禁用样式。 - // 初始即需要展开时直接锁定,避免首次 effect 执行前的点击空窗。 - const animationLockRef = useRef(isVisible); - const [isAnimating, setIsAnimating] = useState(isVisible); - - const lockAnimation = () => { - if (animationLockRef.current) - return false; - animationLockRef.current = true; - setIsAnimating(true); - return true; - }; - - const unlockAnimation = () => { - animationLockRef.current = false; - setIsAnimating(false); - }; - - // ==================== 主题:监听可见性变化并执行弹跳动画 ==================== - // - // useEffect 的职责: - // 1. 监听 isVisible 的变化 - // 2. 根据展开/收起状态执行相应的弹跳动画 - // - // 场景说明: - // - 首次渲染:初始化并执行展开动画 - // - 展开状态:执行从下向上的弹跳进入动画 - // - 收起状态:执行向上弹跳后向下离开的动画 - // ==================== - - useEffect(() => { - // ==================== 场景 1:首次渲染 ==================== - if (isFirstRender.current) { - if (isVisible) { - lockAnimation(); - // 执行展开动画:从下向上炫酷弹跳进入 - controls.start({ - // Y 轴位置关键帧(四个关键帧实现弹跳效果): - // ① 80:从下方 80px 处开始 - // ② -15:快速冲到上方 15px(超出目标位置,产生过冲效果) - // ③ 5:轻微回落到下方 5px - // ④ 0:最终稳定到正常位置 - y: [80, -15, 5, 0], - - // 透明度关键帧: - // ① 0:初始完全透明 - // ② 1:快速变为不透明 - // ③ 1:保持不透明 - // ④ 1:保持不透明 - opacity: [0, 1, 1, 1], - - // 缩放关键帧(配合位置变化,增强弹跳感): - // ① 0.9:从 90% 大小开始(轻微缩小) - // ② 1.05:放大到 105%(轻微放大,配合位置过冲) - // ③ 0.98:缩小到 98%(轻微缩小,配合位置回落) - // ④ 1:回到正常大小 100% - scale: [0.9, 1.05, 0.98, 1], - - transition: { - duration: 0.7, // 总动画时长:0.7 秒 - - // 关键帧时间点分布: - // ① 0:0% 时(0s)在 y=80 处 - // ② 0.5:50% 时(0.35s)在 y=-15 处(过冲峰值) - // ③ 0.75:75% 时(0.525s)在 y=5 处(回落) - // ④ 1:100% 时(0.7s)在 y=0 处(稳定) - times: [0, 0.5, 0.75, 1], - - // 强弹性贝塞尔曲线 [0.34, 1.56, 0.64, 1]: - // - 第一个值 0.34:控制起始加速度(较小,平稳开始) - // - 第二个值 1.56:控制起始减速度(大于 1,产生过冲) - // - 第三个值 0.64:控制结束加速度(小于 1,平滑过渡) - // - 第四个值 1:控制结束减速度(正常结束) - // 这个曲线会产生明显的过冲和回弹效果 - ease: [0.34, 1.56, 0.64, 1], - }, - }).then(() => { - // 动画完成后标记状态 - isExpanded.current = true; // 标记已展开 - isFirstRender.current = false; // 标记首次渲染完成 - unlockAnimation(); - }); - } - else { - // 如果首次渲染时不需要显示,直接标记首次渲染完成 - isFirstRender.current = false; - } - return; - } - - // ==================== 场景 2:展开动画(非首次渲染)==================== - if (isVisible && !isExpanded.current) { - // 正常点击时锁已由按钮同步占用;外部状态触发动画时在这里补充锁定。 - lockAnimation(); - // 从下向上炫酷弹跳进入 - controls.start({ - y: [80, -15, 5, 0], // 位置:下方80px -> 上方15px(过冲)-> 下方5px(回落)-> 正常 - opacity: [0, 1, 1, 1], // 透明度:透明 -> 不透明 -> 不透明 -> 不透明 - scale: [0.9, 1.05, 0.98, 1], // 缩放:90% -> 105% -> 98% -> 100% - transition: { - duration: 0.7, // 总动画时长:0.7 秒 - times: [0, 0.5, 0.75, 1], // 时间点分布 - ease: [0.34, 1.56, 0.64, 1], // 强弹性贝塞尔曲线 - }, - }).then(() => { - isExpanded.current = true; // 标记已展开 - unlockAnimation(); - }); - } - - // ==================== 场景 3:收起动画 ==================== - else if (!isVisible && isExpanded.current) { - lockAnimation(); - // 从上向下炫酷弹跳离开 - controls.start({ - // Y 轴位置关键帧: - // ① 0:从正常位置开始 - // ② -20:向上弹跳到上方 20px(反向过冲) - // ③ 10:回落到下方 10px - // ④ 100:向下离开到 100px 处 - y: [0, -20, 10, 100], - - // 透明度关键帧: - // ① 1:初始不透明 - // ② 1:弹跳过程中保持不透明 - // ③ 0.8:开始淡出 - // ④ 0:完全透明 - opacity: [1, 1, 0.8, 0], - - // 缩放关键帧: - // ① 1:从正常大小开始 - // ② 1.02:轻微放大到 102%(配合向上弹跳) - // ③ 0.95:缩小到 95%(配合回落) - // ④ 0.85:继续缩小到 85%(配合向下离开) - scale: [1, 1.02, 0.95, 0.85], - - transition: { - duration: 0.5, // 总动画时长:0.5 秒(比进入动画短,显得更轻快) - // 关键帧时间点分布: - // ① 0:0% 时(0s)在正常位置 y=0 - // ② 0.2:20% 时(0.1s)在 y=-20 处(快速弹起) - // ③ 0.5:50% 时(0.25s)在 y=10 处(回落) - // ④ 1:100% 时(0.5s)在 y=100 处(完全离开) - times: [0, 0.2, 0.5, 1], - - // 超弹性曲线 [0.68, -0.6, 0.32, 1.6]: - // - 第一个值 0.68:较大的起始加速度 - // - 第二个值 -0.6:负值表示反向过冲(产生夸张的向上弹跳) - // - 第三个值 0.32:较小的结束加速度 - // - 第四个值 1.6:大于 1 的结束减速度(产生回弹效果) - // 这个曲线会产生夸张的反向弹跳效果 - ease: [0.68, -0.6, 0.32, 1.6], - }, - }).then(() => { - isExpanded.current = false; // 标记已收起 - unlockAnimation(); - }); - } - }, [isVisible, controls]); - - // ==================== 返回值 ==================== - return { - controls, // motion 动画控制器,绑定到 motion.div 的 animate 属性 - isAnimating, - // 点击处理器调用后会立即占用锁,确保同一帧内的连续点击也会被拦截。 - tryStartAnimation: lockAnimation, - }; -} diff --git a/src/pages/Container/Map/components/RightUtils/useRightUtilsAnimation.js b/src/pages/Container/Map/components/RightUtils/useRightUtilsAnimation.js deleted file mode 100644 index 378e1e6..0000000 --- a/src/pages/Container/Map/components/RightUtils/useRightUtilsAnimation.js +++ /dev/null @@ -1,232 +0,0 @@ -import { useAnimation } from "motion/react"; -import { useEffect, useRef, useState } from "react"; - -/** - * RightUtils 组件动画 Hook - * - * 功能说明: - * 1. 监听显示/隐藏状态变化,执行从右侧进入/离开的滑动动画 - * 2. 支持模式切换(港口工具 ↔ 分公司工具),实现平滑过渡 - * 3. 支持首次加载动画,初始化时自动进入 - * 4. 使用延迟显示模式,确保动画过渡完整可见 - * - * 动画参数配置: - * - 进入动画:x: 100 → 0(从右向左滑入),opacity: 0 → 1(淡入) - * 时长:0.5s,缓动:easeOut(先快后慢,营造舒适的进入体验) - * - 离开动画:x: 0 → 100(从左向右滑出),opacity: 1 → 0(淡出) - * 时长:0.3s,缓动:easeIn(先慢后快,让用户感觉到响应迅速) - * - * 模式切换动画流程: - * 1. 旧模式离开(0.3s):向右滑出并淡出 - * 2. 更新 displayedMode 为新模式 - * 3. 新模式进入(0.5s):从右侧滑入并淡入 - * - * @param {boolean} shouldShowPort - 是否应该显示港口工具 - * @param {boolean} shouldShowBranchOffice - 是否应该显示分公司工具 - * @returns {object} 返回包含以下属性的对象: - * - 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(); - - // 标记是否是首次渲染 - // 作用:首次渲染时需要特殊处理,确保初始状态正确设置 - const isFirstRender = useRef(true); - - // 标记是否正在执行动画 - // 作用:防止在动画执行过程中触发新的动画,避免动画冲突 - const isAnimating = useRef(false); - - // 延迟显示的模式状态 - // 作用:在模式切换时,先显示旧模式,动画完成后再更新为新模式 - // 这样确保用户看到完整的离开动画和进入动画,实现平滑过渡 - const [displayedMode, setDisplayedMode] = useState( - shouldShowPort ? "port" : shouldShowBranchOffice ? "branchOffice" : null, - ); - - // 元素可见性状态 - // 注意:motion.div 始终挂载,isVisible 只控制内容的显示/隐藏 - // 这样可以保证动画控制器始终有效,避免重复创建/销毁 - const [isVisible, setIsVisible] = useState(false); - - // ==================== 主题:监听状态变化并执行动画 ==================== - // - // useEffect 的职责: - // 1. 监听 shouldShowPort 和 shouldShowBranchOffice 的变化 - // 2. 根据不同场景自动执行相应的动画 - // - // 场景说明: - // - 首次渲染:初始化并执行进入动画 - // - 隐藏状态:执行离开动画并隐藏元素 - // - 模式切换:旧模式离开 -> 新模式进入 - // - 显示状态:从隐藏切换到可见 - // ==================== - - useEffect(() => { - // ==================== 计算当前应该显示的模式 ==================== - // 优先级:港口工具 > 分公司工具 > 隐藏 - const currentMode = shouldShowPort ? "port" : shouldShowBranchOffice ? "branchOffice" : null; - - // ==================== 场景 1:首次渲染 ==================== - if (isFirstRender.current) { - if (currentMode) { - // ----- 步骤 1:立即更新显示的模式 ----- - // 设置 displayedMode 为当前模式,让组件知道应该渲染哪种内容 - setDisplayedMode(currentMode); - - // ----- 步骤 2:设置初始动画状态 ----- - // controls.set() 会将初始状态保存到 controls 对象中 - // 当 motion.div 挂载时,会读取这个初始状态 - controls.set({ - x: 100, // X 轴位置:从右侧 100px 处开始 - opacity: 0, // 透明度:完全透明 - }); - - // ----- 步骤 3:显示元素,触发 motion.div 挂载 ----- - // 设置 isVisible 为 true,触发组件重渲染 - // motion.div 会挂载到 DOM,并读取 controls 的初始状态 - setIsVisible(true); - - // ----- 步骤 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", // 缓动函数:快速开始,缓慢结束,营造舒适的进入体验 - }, - }).then(() => { - // ----- 步骤 5:动画完成,标记首次渲染结束 ----- - isFirstRender.current = false; - }); - } - else { - // 如果首次渲染时不需要显示(currentMode 为 null) - // 直接标记首次渲染完成,不需要执行动画 - isFirstRender.current = false; - } - 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", // 缓动函数:缓慢开始,快速结束,让用户感觉到响应迅速 - }, - }).then(() => { - // 离开动画完成后: - // 1. 隐藏内容(motion.div 仍然挂载,避免重新创建) - setIsVisible(false); - - // 2. 清空显示的模式 - setDisplayedMode(null); - - // 3. 解除动画锁定,允许后续动画 - isAnimating.current = false; - }); - } - return; - } - - // ==================== 场景 3:模式切换或显示 ==================== - - // 检查模式是否发生了变化(港口 ↔ 分公司) - const modeChanged = displayedMode !== currentMode; - - // ----- 情况 A:模式切换(港口 ↔ 分公司)----- - if (modeChanged && !isAnimating.current && isVisible) { - // 标记开始执行动画(防止动画冲突) - isAnimating.current = true; - - // 1. 执行离开动画(旧模式离开) - controls.start({ - x: 100, // 向右移动到 100px 处 - opacity: 0, // 同时淡出 - transition: { - duration: 0.3, // 动画时长:0.3 秒 - ease: "easeIn", // 缓动函数 - }, - }).then(() => { - // 离开动画完成后: - - // 2. 更新显示的模式为新模式 - setDisplayedMode(currentMode); - - // 3. 设置元素到初始位置(右侧外部) - controls.set({ - x: 100, // 确保元素在右侧 100px 处 - opacity: 0, // 确保元素是透明的 - }); - - // 4. 执行进入动画(新模式进入) - controls.start({ - x: 0, // 移动到正常位置 - opacity: 1, // 淡入 - transition: { - duration: 0.5, // 动画时长:0.5 秒 - ease: "easeOut", // 缓动函数 - }, - }).then(() => { - // 动画完成后解除锁定 - isAnimating.current = false; - }); - }); - } - - // ----- 情况 B:元素不可见,但应该显示(例如:从隐藏状态切换到显示)----- - else if (!isVisible && currentMode) { - // 1. 显示元素 - setIsVisible(true); - - // 2. 更新显示的模式 - setDisplayedMode(currentMode); - - // 3. 设置元素到初始位置 - controls.set({ - x: 100, // 右侧 100px 处 - opacity: 0, // 透明 - }); - - // 4. 执行进入动画 - controls.start({ - x: 0, // 移动到正常位置 - opacity: 1, // 淡入 - transition: { - duration: 0.5, // 动画时长:0.5 秒 - ease: "easeOut", // 缓动函数 - }, - }); - } - }, [shouldShowPort, shouldShowBranchOffice, controls, displayedMode, isVisible]); - - // ==================== 返回值 ==================== - return { - controls, // motion 动画控制器,绑定到 motion.div 的 animate 属性 - displayedMode, // 延迟显示的模式,用于决定渲染 port 还是 branchOffice 内容 - isVisible, // 元素可见性状态,用于控制内容的显示/隐藏 - }; -}