refactor(animation): 使用 AnimatePresence 替换自定义动画控制器优化组件动画
- 移除 useBottomUtilsAnimation、useCenterUtilsAnimation、useContentAnimation、useHeaderAnimation、useRightUtilsAnimation 等自定义动画 Hook,统一使用 AnimatePresence 实现动画流程控制master
parent
b544d62dd1
commit
476ead95b5
|
|
@ -14,29 +14,44 @@ import {
|
||||||
resetBottomCurrentIndexMittKey,
|
resetBottomCurrentIndexMittKey,
|
||||||
} from "~/pages/Container/Map/js/mittKey";
|
} from "~/pages/Container/Map/js/mittKey";
|
||||||
import { portUtilsList } from "./portUtilsList";
|
import { portUtilsList } from "./portUtilsList";
|
||||||
import { useBottomUtilsAnimation } from "./useBottomUtilsAnimation";
|
|
||||||
import { useBranchOfficeUtilsAnimation } from "./useBranchOfficeUtilsAnimation";
|
|
||||||
import { usePortUtilsAnimation } from "./usePortUtilsAnimation";
|
import { usePortUtilsAnimation } from "./usePortUtilsAnimation";
|
||||||
import "./index.less";
|
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) {
|
function BottomUtils(props) {
|
||||||
const { currentPort, pureMap, currentBranchOffice, bottomUtilsCurrentIndex } = useContext(Context);
|
const { currentPort, pureMap, currentBranchOffice, bottomUtilsCurrentIndex } = useContext(Context);
|
||||||
|
|
||||||
const [list, setList] = useState([]);
|
const bottomUtilsMode = !pureMap && currentPort && !currentBranchOffice
|
||||||
|
? "port"
|
||||||
const {
|
: !pureMap && currentPort && currentBranchOffice ? "branchOffice" : null;
|
||||||
controls: bottomUtilsControls,
|
const [list, setList] = useState(() => bottomUtilsMode === "port"
|
||||||
displayedMode: bottomUtilsDisplayedMode,
|
? portUtilsList
|
||||||
isVisible: bottomUtilsIsVisible,
|
: bottomUtilsMode === "branchOffice" ? branchOfficeUtilsList : []);
|
||||||
} = useBottomUtilsAnimation(
|
const [listMode, setListMode] = useState(bottomUtilsMode);
|
||||||
!pureMap && currentPort && !currentBranchOffice,
|
|
||||||
!pureMap && currentPort && currentBranchOffice,
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
|
||||||
containerVariants: branchOfficeUtilsContainerVariants,
|
|
||||||
itemVariants: branchOfficeUtilsChildItemVariants,
|
|
||||||
} = useBranchOfficeUtilsAnimation();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
parentControls: portUtilsParentControls,
|
parentControls: portUtilsParentControls,
|
||||||
|
|
@ -49,7 +64,7 @@ function BottomUtils(props) {
|
||||||
} = usePortUtilsAnimation(
|
} = usePortUtilsAnimation(
|
||||||
portUtilsList.length,
|
portUtilsList.length,
|
||||||
bottomUtilsCurrentIndex,
|
bottomUtilsCurrentIndex,
|
||||||
bottomUtilsDisplayedMode === "port",
|
bottomUtilsMode === "port",
|
||||||
);
|
);
|
||||||
|
|
||||||
const resetAllCheck = () => {
|
const resetAllCheck = () => {
|
||||||
|
|
@ -63,16 +78,17 @@ function BottomUtils(props) {
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bottomUtilsDisplayedMode === "port") {
|
if (bottomUtilsMode === "port") {
|
||||||
setList(portUtilsList);
|
setList(portUtilsList);
|
||||||
}
|
}
|
||||||
else if (bottomUtilsDisplayedMode === "branchOffice") {
|
else if (bottomUtilsMode === "branchOffice") {
|
||||||
setList(branchOfficeUtilsList);
|
setList(branchOfficeUtilsList);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
setList([]);
|
setList([]);
|
||||||
}
|
}
|
||||||
}, [bottomUtilsDisplayedMode]);
|
setListMode(bottomUtilsMode);
|
||||||
|
}, [bottomUtilsMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mitt.on(resetBottomCurrentIndexMittKey, () => {
|
mitt.on(resetBottomCurrentIndexMittKey, () => {
|
||||||
|
|
@ -103,7 +119,7 @@ function BottomUtils(props) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderPortUtils = () => {
|
const renderPortUtils = () => {
|
||||||
if (bottomUtilsDisplayedMode === "port") {
|
if (bottomUtilsMode === "port" && listMode === bottomUtilsMode) {
|
||||||
return list.map((item, index) => {
|
return list.map((item, index) => {
|
||||||
const isCurrentActive = bottomUtilsCurrentIndex === index;
|
const isCurrentActive = bottomUtilsCurrentIndex === index;
|
||||||
const hasActiveChildren = bottomUtilsCurrentIndex !== -1;
|
const hasActiveChildren = bottomUtilsCurrentIndex !== -1;
|
||||||
|
|
@ -158,7 +174,7 @@ function BottomUtils(props) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderBranchOfficeUtils = () => {
|
const renderBranchOfficeUtils = () => {
|
||||||
if (bottomUtilsDisplayedMode === "branchOffice") {
|
if (bottomUtilsMode === "branchOffice" && listMode === bottomUtilsMode) {
|
||||||
return (
|
return (
|
||||||
list.map((item, index) => {
|
list.map((item, index) => {
|
||||||
const isCurrentActive = bottomUtilsCurrentIndex === index;
|
const isCurrentActive = bottomUtilsCurrentIndex === index;
|
||||||
|
|
@ -172,7 +188,7 @@ function BottomUtils(props) {
|
||||||
<div className="label">{item.label}</div>
|
<div className="label">{item.label}</div>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{(isCurrentActive && item.list?.length > 0) && (
|
{(isCurrentActive && item.list?.length > 0) && (
|
||||||
<motion.div className="items" initial="hidden" animate="visible" exit="hidden" variants={branchOfficeUtilsContainerVariants}>
|
<motion.div className="items" initial="hidden" animate="visible" exit="hidden" variants={branchOfficeContainerAnimation}>
|
||||||
{item.list?.map((item1, index1) => {
|
{item.list?.map((item1, index1) => {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|
@ -182,7 +198,7 @@ function BottomUtils(props) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
optionsItemsClick(index, index1, item, item1);
|
optionsItemsClick(index, index1, item, item1);
|
||||||
}}
|
}}
|
||||||
variants={branchOfficeUtilsChildItemVariants}
|
variants={branchOfficeItemAnimation}
|
||||||
>
|
>
|
||||||
<div className="child_label">{item1.label}</div>
|
<div className="child_label">{item1.label}</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
@ -200,14 +216,18 @@ function BottomUtils(props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="map_content_bottom_utils_container">
|
<div className="map_content_bottom_utils_container">
|
||||||
<motion.div className={bottomUtilsDisplayedMode === "port" ? "bottom_utils port" : "bottom_utils branch_office"} animate={bottomUtilsControls}>
|
<AnimatePresence mode="wait">
|
||||||
{bottomUtilsIsVisible && (
|
{bottomUtilsMode && (
|
||||||
<>
|
<motion.div
|
||||||
|
key={bottomUtilsMode}
|
||||||
|
className={bottomUtilsMode === "port" ? "bottom_utils port" : "bottom_utils branch_office"}
|
||||||
|
{...bottomUtilsAnimation}
|
||||||
|
>
|
||||||
{renderPortUtils()}
|
{renderPortUtils()}
|
||||||
{renderBranchOfficeUtils()}
|
{renderBranchOfficeUtils()}
|
||||||
</>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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, // 元素可见性状态,用于控制内容的显示/隐藏
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -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 属性
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
* 动画流程详解:
|
* 动画流程详解:
|
||||||
*
|
*
|
||||||
* 【初始显示】
|
* 【初始显示】
|
||||||
* - 使用 useBottomUtilsAnimation 的主容器动画
|
* - 使用 BottomUtils 声明式主容器动画
|
||||||
* - 不执行父级的依次显示动画,保持静止
|
* - 不执行父级的依次显示动画,保持静止
|
||||||
*
|
*
|
||||||
* 【展开动画序列】(点击父级展开子级)
|
* 【展开动画序列】(点击父级展开子级)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useContext, useState } from "react";
|
import { useContext, useState } from "react";
|
||||||
import statisticsTitleImg from "~/assets/images/map_bi/center_utils/statistics_title.png";
|
import statisticsTitleImg from "~/assets/images/map_bi/center_utils/statistics_title.png";
|
||||||
import guangImg from "~/assets/images/map_bi/center_utils/tabguang.png";
|
import guangImg from "~/assets/images/map_bi/center_utils/tabguang.png";
|
||||||
|
|
@ -15,9 +15,39 @@ import {
|
||||||
resetAllBottomUtilsCheckMittKey,
|
resetAllBottomUtilsCheckMittKey,
|
||||||
resetBottomCurrentIndexMittKey,
|
resetBottomCurrentIndexMittKey,
|
||||||
} from "~/pages/Container/Map/js/mittKey";
|
} from "~/pages/Container/Map/js/mittKey";
|
||||||
import { useCenterUtilsAnimation } from "./useCenterUtilsAnimation";
|
|
||||||
import "./index.less";
|
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) {
|
function CenterUtils(props) {
|
||||||
const { currentPort, currentBranchOffice, pureMap, mapMethods } = useContext(Context);
|
const { currentPort, currentBranchOffice, pureMap, mapMethods } = useContext(Context);
|
||||||
|
|
||||||
|
|
@ -28,20 +58,7 @@ function CenterUtils(props) {
|
||||||
];
|
];
|
||||||
const [activeIndex, setActiveIndex] = useState(1);
|
const [activeIndex, setActiveIndex] = useState(1);
|
||||||
|
|
||||||
const { controls: containerControls, isVisible: isContainerVisible } = useCenterUtilsAnimation(
|
const isVisible = currentPort === "00003" && !currentBranchOffice && !pureMap;
|
||||||
(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 onChangeArea = (index) => {
|
const onChangeArea = (index) => {
|
||||||
if (index === activeIndex)
|
if (index === activeIndex)
|
||||||
|
|
@ -57,9 +74,9 @@ function CenterUtils(props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="map_content_center_options_container">
|
<div className="map_content_center_options_container">
|
||||||
<motion.div animate={containerControls} className="center_utils">
|
<AnimatePresence>
|
||||||
{isContainerVisible && (
|
{isVisible && (
|
||||||
<>
|
<motion.div className="center_utils" {...containerAnimation}>
|
||||||
<div className="center_options">
|
<div className="center_options">
|
||||||
<div
|
<div
|
||||||
className="guang"
|
className="guang"
|
||||||
|
|
@ -81,8 +98,9 @@ function CenterUtils(props) {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="statistics">
|
<div className="statistics">
|
||||||
<motion.div animate={westControls}>
|
<AnimatePresence>
|
||||||
{isWestVisible && (
|
{(activeIndex === 0 || activeIndex === 1) && (
|
||||||
|
<motion.div key="west" {...statisticAnimation}>
|
||||||
<div className="statistic">
|
<div className="statistic">
|
||||||
<div className="title" style={{ backgroundImage: `url(${statisticsTitleImg})` }}>西港区</div>
|
<div className="title" style={{ backgroundImage: `url(${statisticsTitleImg})` }}>西港区</div>
|
||||||
<div className="info">
|
<div className="info">
|
||||||
|
|
@ -90,10 +108,12 @@ function CenterUtils(props) {
|
||||||
<div className="value">车辆:33辆</div>
|
<div className="value">车辆:33辆</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</AnimatePresence>
|
||||||
<motion.div animate={eastControls}>
|
<AnimatePresence>
|
||||||
{isEastVisible && (
|
{(activeIndex === 2 || activeIndex === 1) && (
|
||||||
|
<motion.div key="east" {...statisticAnimation}>
|
||||||
<div className="statistic">
|
<div className="statistic">
|
||||||
<div className="title" style={{ backgroundImage: `url(${statisticsTitleImg})` }}>东港区</div>
|
<div className="title" style={{ backgroundImage: `url(${statisticsTitleImg})` }}>东港区</div>
|
||||||
<div className="info">
|
<div className="info">
|
||||||
|
|
@ -101,12 +121,13 @@ function CenterUtils(props) {
|
||||||
<div className="value">车辆:33辆</div>
|
<div className="value">车辆:33辆</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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, // 延迟的可见性状态,用于条件渲染(等待离开动画完成)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useContext } from "react";
|
import { useContext, useState } from "react";
|
||||||
import collapseMenuImg1 from "~/assets/images/map_bi/content/collapse_menu1.png";
|
import collapseMenuImg1 from "~/assets/images/map_bi/content/collapse_menu1.png";
|
||||||
import collapseMenuImg2 from "~/assets/images/map_bi/content/collapse_menu2.png";
|
import collapseMenuImg2 from "~/assets/images/map_bi/content/collapse_menu2.png";
|
||||||
import collapseMenuBg1 from "~/assets/images/map_bi/content/collapse_menu_bg1.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 PortXiaoFang from "~/pages/Container/Map/components/Content/port/XiaoFang";
|
||||||
import PortZhongDian from "~/pages/Container/Map/components/Content/port/ZhongDian";
|
import PortZhongDian from "~/pages/Container/Map/components/Content/port/ZhongDian";
|
||||||
import { Context } from "~/pages/Container/Map/js/context";
|
import { Context } from "~/pages/Container/Map/js/context";
|
||||||
import { useContentAnimation } from "./useContentAnimation";
|
|
||||||
import "./index.less";
|
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) {
|
function Content(props) {
|
||||||
const { currentPort, currentBranchOffice, pureMap, bottomUtilsCurrentIndex } = useContext(Context);
|
const { currentPort, currentBranchOffice, pureMap, bottomUtilsCurrentIndex } = useContext(Context);
|
||||||
|
const [isLeftCollapsed, setIsLeftCollapsed] = useState(false);
|
||||||
|
const [isRightCollapsed, setIsRightCollapsed] = useState(false);
|
||||||
|
|
||||||
const {
|
const bottomUtilsCurrentType = currentBranchOffice
|
||||||
controls: indexInfoControls,
|
? branchOfficeUtilsList[bottomUtilsCurrentIndex]?.type || ""
|
||||||
isVisible: isIndexInfoVisible,
|
: portUtilsList[bottomUtilsCurrentIndex]?.type || "";
|
||||||
} = useContentAnimation({
|
const contentKey = `${currentPort}_${currentBranchOffice}_${bottomUtilsCurrentIndex}`;
|
||||||
currentContent: { currentPort, currentBranchOffice },
|
const isPortContent = currentPort === "00003" && !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 renderPortContent = () => {
|
const renderPortContent = () => {
|
||||||
const bottomUtilsCurrentType = (leftDisplayedContent.bottomUtilsCurrentIndex !== -1 && portUtilsList[leftDisplayedContent.bottomUtilsCurrentIndex])
|
const contentMap = {
|
||||||
? portUtilsList[leftDisplayedContent.bottomUtilsCurrentIndex].type
|
"": PortIndex,
|
||||||
: "";
|
"camera": PortIndex,
|
||||||
if (bottomUtilsCurrentType === "" || bottomUtilsCurrentType === "camera")
|
"door": PortMenJin,
|
||||||
return <PortIndex />;
|
"fire": PortXiaoFang,
|
||||||
if (bottomUtilsCurrentType === "door")
|
"danger": PortWeiXian,
|
||||||
return <PortMenJin />;
|
"weather": PortQiXiang,
|
||||||
if (bottomUtilsCurrentType === "fire")
|
"people": PortRenYuan,
|
||||||
return <PortXiaoFang />;
|
"project": PortZhongDian,
|
||||||
if (bottomUtilsCurrentType === "danger")
|
"closedArea": PortFengBi,
|
||||||
return <PortWeiXian />;
|
};
|
||||||
if (bottomUtilsCurrentType === "weather")
|
const Component = contentMap[bottomUtilsCurrentType];
|
||||||
return <PortQiXiang />;
|
return Component ? <Component /> : null;
|
||||||
if (bottomUtilsCurrentType === "people")
|
|
||||||
return <PortRenYuan />;
|
|
||||||
if (bottomUtilsCurrentType === "project")
|
|
||||||
return <PortZhongDian />;
|
|
||||||
if (bottomUtilsCurrentType === "closedArea")
|
|
||||||
return <PortFengBi />;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderBranchOfficeContentLeft = () => {
|
const renderBranchOfficeContentLeft = () => {
|
||||||
const bottomUtilsCurrentType = (leftDisplayedContent.bottomUtilsCurrentIndex !== -1 && branchOfficeUtilsList[leftDisplayedContent.bottomUtilsCurrentIndex])
|
const contentMap = {
|
||||||
? branchOfficeUtilsList[leftDisplayedContent.bottomUtilsCurrentIndex].type
|
"": BranchOfficeIndexLeft,
|
||||||
: "";
|
"danger": BranchWeiXian,
|
||||||
if (bottomUtilsCurrentType === "")
|
"door": BranchMenJin,
|
||||||
return <BranchOfficeIndexLeft />;
|
"fire": BranchXiaoFang,
|
||||||
if (bottomUtilsCurrentType === "danger")
|
"people": BranchRenYuan,
|
||||||
return <BranchWeiXian />;
|
"weather": BranchQiXiang,
|
||||||
if (bottomUtilsCurrentType === "door")
|
"project": BranchZhongDian,
|
||||||
return <BranchMenJin />;
|
"closedArea": BranchFengBi,
|
||||||
if (bottomUtilsCurrentType === "fire")
|
"camera": BranchCamera,
|
||||||
return <BranchXiaoFang />;
|
};
|
||||||
if (bottomUtilsCurrentType === "people")
|
const Component = contentMap[bottomUtilsCurrentType];
|
||||||
return <BranchRenYuan />;
|
return Component ? <Component /> : null;
|
||||||
if (bottomUtilsCurrentType === "weather")
|
|
||||||
return <BranchQiXiang />;
|
|
||||||
if (bottomUtilsCurrentType === "project")
|
|
||||||
return <BranchZhongDian />;
|
|
||||||
if (bottomUtilsCurrentType === "closedArea")
|
|
||||||
return <BranchFengBi />;
|
|
||||||
if (bottomUtilsCurrentType === "camera")
|
|
||||||
return <BranchCamera />;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderBranchOfficeContentRight = () => {
|
const renderCollapseButton = ({ side, isCollapsed, setIsCollapsed }) => {
|
||||||
const bottomUtilsCurrentType = (rightDisplayedContent.bottomUtilsCurrentIndex !== -1 && branchOfficeUtilsList[rightDisplayedContent.bottomUtilsCurrentIndex])
|
const isPort = isPortContent;
|
||||||
? branchOfficeUtilsList[rightDisplayedContent.bottomUtilsCurrentIndex].type
|
const collapsedX = side === "left" ? -465 : 465;
|
||||||
: "";
|
const containerRotation = !isPort && side === "right" ? 180 : 0;
|
||||||
if (bottomUtilsCurrentType === "")
|
const imageRotation = isPort
|
||||||
return <BranchOfficeIndexRight />;
|
? (isCollapsed ? 180 : 0)
|
||||||
};
|
: (isCollapsed ? 0 : 180);
|
||||||
|
|
||||||
const renderContent = () => {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<motion.div
|
||||||
{isIndexInfoVisible && (
|
className={`collapse_menu ${isPort ? "port" : "branch_office"} ${side}`}
|
||||||
<motion.div animate={indexInfoControls} className="map_content_container__indexinfo">
|
style={{ backgroundImage: `url(${isPort ? collapseMenuBg1 : collapseMenuBg2})` }}
|
||||||
<IndexInfo history={props.history} />
|
animate={{ x: isCollapsed ? collapsedX : 0, rotate: containerRotation }}
|
||||||
</motion.div>
|
transition={{ duration: isCollapsed ? 0.3 : 0.5, ease: isCollapsed ? "easeIn" : "easeOut" }}
|
||||||
)}
|
onClick={() => setIsCollapsed(value => !value)}
|
||||||
{isLeftVisible && (
|
>
|
||||||
<motion.div
|
<motion.img
|
||||||
initial={{ x: -300, opacity: 0 }}
|
src={isPort ? collapseMenuImg1 : collapseMenuImg2}
|
||||||
animate={leftControls}
|
alt=""
|
||||||
className={`map_content_container__content ${(!leftDisplayedContent.currentPort || (leftDisplayedContent.currentPort === "00003" && !leftDisplayedContent.currentBranchOffice)) ? "port" : "branch_office"}`}
|
animate={{ x: "-50%", y: "-50%", rotate: imageRotation }}
|
||||||
style={{ left: 35 }}
|
transition={{ duration: isCollapsed ? 0.3 : 0.5, ease: isCollapsed ? "easeIn" : "easeOut" }}
|
||||||
>
|
style={{ left: "50%", top: "50%", position: "absolute" }}
|
||||||
{(leftDisplayedContent.currentPort === "00003" && !leftDisplayedContent.currentBranchOffice) && renderPortContent()}
|
/>
|
||||||
{leftDisplayedContent.currentBranchOffice && renderBranchOfficeContentLeft()}
|
</motion.div>
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
{isRightVisible && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ x: 300, opacity: 0 }}
|
|
||||||
animate={rightControls}
|
|
||||||
className={`map_content_container__content ${(!leftDisplayedContent.currentPort || (leftDisplayedContent.currentPort === "00003" && !leftDisplayedContent.currentBranchOffice)) ? "port" : "branch_office"}`}
|
|
||||||
style={{ right: 35 }}
|
|
||||||
>
|
|
||||||
{rightDisplayedContent.currentBranchOffice && renderBranchOfficeContentRight()}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderCollapseMenu = () => {
|
|
||||||
if (pureMap)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
if (currentPort === "00003" && !currentBranchOffice) {
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
className="collapse_menu port left"
|
|
||||||
style={{ backgroundImage: `url(${collapseMenuBg1})` }}
|
|
||||||
animate={leftMenuControls}
|
|
||||||
onClick={handleLeftCollapse}
|
|
||||||
initial={{ x: 0 }}
|
|
||||||
>
|
|
||||||
<motion.img
|
|
||||||
src={collapseMenuImg1}
|
|
||||||
alt=""
|
|
||||||
animate={{
|
|
||||||
x: "-50%",
|
|
||||||
y: "-50%",
|
|
||||||
rotate: isLeftCollapsed ? 180 : 0,
|
|
||||||
}}
|
|
||||||
transition={{ duration: isLeftCollapsed ? 0.3 : 0.5, ease: isLeftCollapsed ? "easeIn" : "easeOut" }}
|
|
||||||
style={{ left: "50%", top: "50%", position: "absolute" }}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentBranchOffice) {
|
|
||||||
const bottomUtilsCurrentType = bottomUtilsCurrentIndex !== -1 ? branchOfficeUtilsList[bottomUtilsCurrentIndex].type : "";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{bottomUtilsCurrentType !== "camera" && (
|
|
||||||
<motion.div
|
|
||||||
className="collapse_menu branch_office left"
|
|
||||||
style={{ backgroundImage: `url(${collapseMenuBg2})` }}
|
|
||||||
animate={leftMenuControls}
|
|
||||||
onClick={handleLeftCollapse}
|
|
||||||
initial={{ x: 0 }}
|
|
||||||
>
|
|
||||||
<motion.img
|
|
||||||
src={collapseMenuImg2}
|
|
||||||
alt=""
|
|
||||||
animate={{
|
|
||||||
x: "-50%",
|
|
||||||
y: "-50%",
|
|
||||||
rotate: isLeftCollapsed ? 0 : 180,
|
|
||||||
}}
|
|
||||||
transition={{ duration: isLeftCollapsed ? 0.3 : 0.5, ease: isLeftCollapsed ? "easeIn" : "easeOut" }}
|
|
||||||
style={{ left: "50%", top: "50%", position: "absolute" }}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
{bottomUtilsCurrentIndex === -1 && (
|
|
||||||
<motion.div
|
|
||||||
className="collapse_menu branch_office right"
|
|
||||||
style={{ backgroundImage: `url(${collapseMenuBg2})` }}
|
|
||||||
animate={rightMenuControls}
|
|
||||||
onClick={handleRightCollapse}
|
|
||||||
initial={{ x: 0, rotate: 180 }}
|
|
||||||
>
|
|
||||||
<motion.img
|
|
||||||
src={collapseMenuImg2}
|
|
||||||
alt=""
|
|
||||||
animate={{
|
|
||||||
x: "-50%",
|
|
||||||
y: "-50%",
|
|
||||||
rotate: isRightCollapsed ? 0 : 180,
|
|
||||||
}}
|
|
||||||
transition={{ duration: isRightCollapsed ? 0.3 : 0.5, ease: isRightCollapsed ? "easeIn" : "easeOut" }}
|
|
||||||
style={{ left: "50%", top: "50%", position: "absolute" }}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="map_content_container">
|
<div className="map_content_container">
|
||||||
{renderContent()}
|
<AnimatePresence mode="wait">
|
||||||
{renderCollapseMenu()}
|
{!currentPort && !currentBranchOffice && (
|
||||||
|
<motion.div key="index" className="map_content_container__indexinfo" {...indexAnimation}>
|
||||||
|
<IndexInfo history={props.history} />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{!pureMap && !isLeftCollapsed && (isPortContent || currentBranchOffice) && (
|
||||||
|
<motion.div
|
||||||
|
key={`left_${contentKey}`}
|
||||||
|
className={`map_content_container__content ${isPortContent ? "port" : "branch_office"}`}
|
||||||
|
style={{ left: 35 }}
|
||||||
|
{...panelAnimation("left")}
|
||||||
|
>
|
||||||
|
{isPortContent ? renderPortContent() : renderBranchOfficeContentLeft()}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{!pureMap && !isRightCollapsed && currentBranchOffice && (
|
||||||
|
<motion.div
|
||||||
|
key={`right_${contentKey}`}
|
||||||
|
className="map_content_container__content branch_office"
|
||||||
|
style={{ right: 35 }}
|
||||||
|
{...panelAnimation("right")}
|
||||||
|
>
|
||||||
|
{bottomUtilsCurrentType === "" && <BranchOfficeIndexRight />}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{!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,
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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, // 菜单按钮的动画控制器,用于控制折叠按钮的位置动画
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useContext } from "react";
|
import { useContext } from "react";
|
||||||
import backImg1 from "~/assets/images/map_bi/back1.png";
|
import backImg1 from "~/assets/images/map_bi/back1.png";
|
||||||
import backImg2 from "~/assets/images/map_bi/back2.png";
|
import backImg2 from "~/assets/images/map_bi/back2.png";
|
||||||
|
|
@ -16,13 +16,19 @@ import {
|
||||||
resetAllBottomUtilsCheckMittKey,
|
resetAllBottomUtilsCheckMittKey,
|
||||||
resetBottomCurrentIndexMittKey,
|
resetBottomCurrentIndexMittKey,
|
||||||
} from "~/pages/Container/Map/js/mittKey";
|
} from "~/pages/Container/Map/js/mittKey";
|
||||||
import { useHeaderAnimation } from "./useHeaderAnimation";
|
|
||||||
import "./index.less";
|
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) {
|
function Header(props) {
|
||||||
const { currentPort, currentBranchOffice, mapMethods, area } = useContext(Context);
|
const { currentPort, currentBranchOffice, mapMethods, area } = useContext(Context);
|
||||||
|
|
||||||
const { controls, displayedTitle } = useHeaderAnimation(props.headerTitle);
|
const isPortTitle = props.headerTitle === "秦港股份安全监管平台";
|
||||||
|
|
||||||
const onBack = () => {
|
const onBack = () => {
|
||||||
sessionStorage.removeItem("mapCurrentBranchOfficeId");
|
sessionStorage.removeItem("mapCurrentBranchOfficeId");
|
||||||
|
|
@ -73,23 +79,25 @@ function Header(props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="map_content_header_container">
|
<div className="map_content_header_container">
|
||||||
<motion.header
|
<AnimatePresence mode="wait">
|
||||||
animate={controls}
|
<motion.header
|
||||||
className={`${displayedTitle === "秦港股份安全监管平台" ? "port" : "branch_office"}`}
|
key={props.headerTitle}
|
||||||
style={{ backgroundImage: `url(${displayedTitle === "秦港股份安全监管平台" ? topImg1 : topImg2})` }}
|
{...headerAnimation}
|
||||||
>
|
className={isPortTitle ? "port" : "branch_office"}
|
||||||
{(displayedTitle === "秦港股份安全监管平台") && (
|
style={{ backgroundImage: `url(${isPortTitle ? topImg1 : topImg2})` }}
|
||||||
<div style={{ backgroundImage: `url(${backImg1})` }} className="back" onClick={onBack} />
|
>
|
||||||
)}
|
{isPortTitle
|
||||||
{displayedTitle !== "秦港股份安全监管平台" && (
|
? <div style={{ backgroundImage: `url(${backImg1})` }} className="back" onClick={onBack} />
|
||||||
<div className="back" onClick={onBack}>
|
: (
|
||||||
<img src={backImg2} alt="" />
|
<div className="back" onClick={onBack}>
|
||||||
<div>返回</div>
|
<img src={backImg2} alt="" />
|
||||||
</div>
|
<div>返回</div>
|
||||||
)}
|
</div>
|
||||||
<div className="title">{displayedTitle}</div>
|
)}
|
||||||
<div style={{ backgroundImage: `url(${guangImg})` }} className="guang" />
|
<div className="title">{props.headerTitle}</div>
|
||||||
</motion.header>
|
<div style={{ backgroundImage: `url(${guangImg})` }} className="guang" />
|
||||||
|
</motion.header>
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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, // 延迟显示的标题,用于在动画过渡期间保持视觉连续性
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { message } from "antd";
|
import { message } from "antd";
|
||||||
import { produce } from "immer";
|
import { produce } from "immer";
|
||||||
import { motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useContext, useEffect, useState } from "react";
|
import { useContext, useEffect, useRef, useState } from "react";
|
||||||
import tooltipImg2 from "~/assets/images/map_bi/right_utils/branch_office/bg11.png";
|
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 buttonBg from "~/assets/images/map_bi/right_utils/branch_office/button.png";
|
||||||
import tooltipImg1 from "~/assets/images/map_bi/right_utils/port/tooltip.png";
|
import tooltipImg1 from "~/assets/images/map_bi/right_utils/port/tooltip.png";
|
||||||
|
|
@ -14,33 +14,40 @@ import {
|
||||||
deletePeoplePositionPointMittKey,
|
deletePeoplePositionPointMittKey,
|
||||||
resetAllBottomUtilsCheckMittKey,
|
resetAllBottomUtilsCheckMittKey,
|
||||||
} from "~/pages/Container/Map/js/mittKey";
|
} from "~/pages/Container/Map/js/mittKey";
|
||||||
import { useChildMenuAnimation } from "./useChildMenuAnimation";
|
|
||||||
import { useRightUtilsAnimation } from "./useRightUtilsAnimation";
|
|
||||||
import { utilsList } from "./utilsList";
|
import { utilsList } from "./utilsList";
|
||||||
import "./index.less";
|
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) {
|
function RightUtils(props) {
|
||||||
const { currentPort, mapMethods, pureMap, currentBranchOffice, bottomUtilsCurrentIndex } = useContext(Context);
|
const { currentPort, mapMethods, pureMap, currentBranchOffice, bottomUtilsCurrentIndex } = useContext(Context);
|
||||||
|
|
||||||
const [list, setList] = useState(utilsList);
|
const [list, setList] = useState(utilsList);
|
||||||
const [isShowChildLevel, setIsShowChildLevel] = useState(true);
|
const [isShowChildLevel, setIsShowChildLevel] = useState(true);
|
||||||
|
const childMenuAnimationLockRef = useRef(true);
|
||||||
const {
|
const [isChildMenuAnimating, setIsChildMenuAnimating] = useState(true);
|
||||||
controls: rightUtilsControls,
|
const rightUtilsMode = currentPort && !currentBranchOffice
|
||||||
displayedMode: rightUtilsDisplayedMode,
|
? "port"
|
||||||
isVisible: rightUtilsIsVisible,
|
: currentBranchOffice && bottomUtilsCurrentIndex !== -1 ? "branchOffice" : null;
|
||||||
} = useRightUtilsAnimation(
|
|
||||||
currentPort && !currentBranchOffice,
|
|
||||||
currentBranchOffice && bottomUtilsCurrentIndex !== -1,
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
|
||||||
controls: childMenuControls,
|
|
||||||
isAnimating: isChildMenuAnimating,
|
|
||||||
tryStartAnimation: tryStartChildMenuAnimation,
|
|
||||||
} = useChildMenuAnimation(
|
|
||||||
currentBranchOffice && bottomUtilsCurrentIndex !== -1 && isShowChildLevel,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bottomUtilsCurrentIndex === -1)
|
if (bottomUtilsCurrentIndex === -1)
|
||||||
|
|
@ -146,7 +153,7 @@ function RightUtils(props) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderPortUtils = () => {
|
const renderPortUtils = () => {
|
||||||
if (rightUtilsDisplayedMode === "port") {
|
if (rightUtilsMode === "port") {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{list.map((item, index) => (
|
{list.map((item, index) => (
|
||||||
|
|
@ -166,10 +173,23 @@ function RightUtils(props) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderBranchOfficeUtils = () => {
|
const renderBranchOfficeUtils = () => {
|
||||||
if (rightUtilsDisplayedMode === "branchOffice") {
|
if (rightUtilsMode === "branchOffice") {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<motion.div className="options" animate={childMenuControls}>
|
<motion.div
|
||||||
|
className="options"
|
||||||
|
initial="hidden"
|
||||||
|
animate={isShowChildLevel ? "visible" : "hidden"}
|
||||||
|
variants={childMenuAnimation}
|
||||||
|
onAnimationStart={() => {
|
||||||
|
childMenuAnimationLockRef.current = true;
|
||||||
|
setIsChildMenuAnimating(true);
|
||||||
|
}}
|
||||||
|
onAnimationComplete={() => {
|
||||||
|
childMenuAnimationLockRef.current = false;
|
||||||
|
setIsChildMenuAnimating(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{list.map((item, index) => (
|
{list.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
|
|
@ -185,8 +205,11 @@ function RightUtils(props) {
|
||||||
className={`bg ${isShowChildLevel ? "active" : ""} ${isChildMenuAnimating ? "disabled" : ""}`}
|
className={`bg ${isShowChildLevel ? "active" : ""} ${isChildMenuAnimating ? "disabled" : ""}`}
|
||||||
aria-disabled={isChildMenuAnimating}
|
aria-disabled={isChildMenuAnimating}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!tryStartChildMenuAnimation())
|
if (childMenuAnimationLockRef.current)
|
||||||
return;
|
return;
|
||||||
|
// 点击时同步占用锁,避免等待下一次渲染期间发生连续点击。
|
||||||
|
childMenuAnimationLockRef.current = true;
|
||||||
|
setIsChildMenuAnimating(true);
|
||||||
setIsShowChildLevel(visible => !visible);
|
setIsShowChildLevel(visible => !visible);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -200,17 +223,18 @@ function RightUtils(props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="map_content_right_utils_container">
|
<div className="map_content_right_utils_container">
|
||||||
<motion.div
|
<AnimatePresence mode="wait">
|
||||||
className={rightUtilsDisplayedMode === "port" ? "right_utils port" : "right_utils branch_office"}
|
{rightUtilsMode && (
|
||||||
animate={rightUtilsControls}
|
<motion.div
|
||||||
>
|
key={rightUtilsMode}
|
||||||
{rightUtilsIsVisible && (
|
className={rightUtilsMode === "port" ? "right_utils port" : "right_utils branch_office"}
|
||||||
<>
|
{...rightUtilsAnimation}
|
||||||
|
>
|
||||||
{renderPortUtils()}
|
{renderPortUtils()}
|
||||||
{renderBranchOfficeUtils()}
|
{renderBranchOfficeUtils()}
|
||||||
</>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -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, // 元素可见性状态,用于控制内容的显示/隐藏
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue