feat(RightUtils): 优化子菜单动画点击锁定与界面交互

- 新增 isAnimating 状态和 tryStartAnimation 方法防止动画期间重复点击
- 子菜单背景按钮禁用时添加 disabled 样式及无障碍 aria-disabled 属性
- 按钮点击事件中增加动画锁定判断,避免动画未完成前状态切换
- useChildMenuAnimation Hook 内部实现动画锁定机制,保证动画开始至结束期间禁用重复操作
- 在首次渲染和动画完成处正确管理动画锁,确保状态与动画同步
- index.less 中新增 disabled 样式防止用户交互,保持按钮和菜单状态一致
master
LiuJiaNan 2026-07-15 09:36:02 +08:00
parent 42961e417c
commit b544d62dd1
3 changed files with 47 additions and 4 deletions

View File

@ -34,7 +34,11 @@ function RightUtils(props) {
currentBranchOffice && bottomUtilsCurrentIndex !== -1,
);
const { controls: childMenuControls } = useChildMenuAnimation(
const {
controls: childMenuControls,
isAnimating: isChildMenuAnimating,
tryStartAnimation: tryStartChildMenuAnimation,
} = useChildMenuAnimation(
currentBranchOffice && bottomUtilsCurrentIndex !== -1 && isShowChildLevel,
);
@ -178,8 +182,13 @@ function RightUtils(props) {
))}
</motion.div>
<div
className={`bg ${isShowChildLevel ? "active" : ""}`}
onClick={() => setIsShowChildLevel(!isShowChildLevel)}
className={`bg ${isShowChildLevel ? "active" : ""} ${isChildMenuAnimating ? "disabled" : ""}`}
aria-disabled={isChildMenuAnimating}
onClick={() => {
if (!tryStartChildMenuAnimation())
return;
setIsShowChildLevel(visible => !visible);
}}
>
<img src={buttonBg} alt="" />
</div>

View File

@ -64,6 +64,12 @@
transform: rotate(0deg);
}
// 子菜单动画执行期间禁止重复点击,确保按钮方向与菜单展开状态始终一致。
&.disabled {
pointer-events: none;
cursor: not-allowed;
}
img {
width: 101px;
height: 100px;

View File

@ -1,5 +1,5 @@
import { useAnimation } from "motion/react";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
/**
* RightUtils 子菜单动画 Hook
@ -60,6 +60,24 @@ export function useChildMenuAnimation(isVisible) {
// 作用:首次渲染时需要特殊处理,确保初始状态正确设置
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 的职责:
@ -76,6 +94,7 @@ export function useChildMenuAnimation(isVisible) {
// ==================== 场景 1首次渲染 ====================
if (isFirstRender.current) {
if (isVisible) {
lockAnimation();
// 执行展开动画:从下向上炫酷弹跳进入
controls.start({
// Y 轴位置关键帧(四个关键帧实现弹跳效果):
@ -121,6 +140,7 @@ export function useChildMenuAnimation(isVisible) {
// 动画完成后标记状态
isExpanded.current = true; // 标记已展开
isFirstRender.current = false; // 标记首次渲染完成
unlockAnimation();
});
}
else {
@ -132,6 +152,8 @@ export function useChildMenuAnimation(isVisible) {
// ==================== 场景 2展开动画非首次渲染====================
if (isVisible && !isExpanded.current) {
// 正常点击时锁已由按钮同步占用;外部状态触发动画时在这里补充锁定。
lockAnimation();
// 从下向上炫酷弹跳进入
controls.start({
y: [80, -15, 5, 0], // 位置下方80px -> 上方15px过冲-> 下方5px回落-> 正常
@ -144,11 +166,13 @@ export function useChildMenuAnimation(isVisible) {
},
}).then(() => {
isExpanded.current = true; // 标记已展开
unlockAnimation();
});
}
// ==================== 场景 3收起动画 ====================
else if (!isVisible && isExpanded.current) {
lockAnimation();
// 从上向下炫酷弹跳离开
controls.start({
// Y 轴位置关键帧:
@ -191,6 +215,7 @@ export function useChildMenuAnimation(isVisible) {
},
}).then(() => {
isExpanded.current = false; // 标记已收起
unlockAnimation();
});
}
}, [isVisible, controls]);
@ -198,5 +223,8 @@ export function useChildMenuAnimation(isVisible) {
// ==================== 返回值 ====================
return {
controls, // motion 动画控制器,绑定到 motion.div 的 animate 属性
isAnimating,
// 点击处理器调用后会立即占用锁,确保同一帧内的连续点击也会被拦截。
tryStartAnimation: lockAnimation,
};
}