本文详解 react-beautiful-dnd 在 mui accordion 中嵌套多个动态 droppable 列表时拖拽失效的根本原因,并提供可复用的自定义 accordion 实现方案,确保每个列表独立响应拖拽、状态更新准确无误。
本文详解 react-beautiful-dnd 在 mui accordion 中嵌套多个动态 droppable 列表时拖拽失效的根本原因,并提供可复用的自定义 accordion 实现方案,确保每个列表独立响应拖拽、状态更新准确无误。
在使用 react-beautiful-dnd 构建多组独立可拖拽列表(如 10 个手风琴面板,每面板内含 10 个可排序项)时,一个常见却极易被忽视的问题是:仅首个列表支持正常拖放,其余列表拖拽时无法接收放置(drop)。该问题并非源于 onDragEnd 逻辑错误,而根植于 MUI Accordion 的渲染机制与 react-beautiful-dnd 的 DOM 生命周期冲突。
? 问题本质:MUI Accordion 的条件渲染破坏了 Droppable 的稳定性
MUI 的
- 持续监听拖拽进入/离开事件;
- 正确计算占位符(placeholder)位置;
- 维持 droppableId 的唯一性与生命周期一致性。
一旦 Droppable 因 Accordion 折叠而被销毁,其内部状态丢失,onDragEnd 中读取的 result.destination 将为 null(即无法 drop),导致“拖不动”假象——这正是原代码中 if (!result.destination) return; 总是提前退出的原因。
✅ 正确解法:用受控状态 + CSS 可见性替代 DOM 卸载
我们需替换 MUI Accordion 为轻量级、不卸载子组件的自定义实现,仅通过 visibility: hidden 或 height: 0 + overflow: hidden 控制视觉展开/收起,确保 Droppable 始终挂载。
以下是关键改造步骤与完整可运行代码:
1. 自定义 Accordion 组件(保持 Droppable 持久化)
import * as React from 'react';
import { Paper, Stack, Typography, IconButton } from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
interface CustomAccordionProps {
title: string;
expanded: boolean;
onToggle: () => void;
children: React.ReactNode;
}
const CustomAccordion = ({
title,
expanded,
onToggle,
children
}: CustomAccordionProps) => {
return (
<paper sx="{{" mb: overflow: borderradius:>
{/* Header — 始终存在 */}
<stack direction="row" justifycontent="space-between" alignitems="center" p="{2}" sx="{{" bgcolor: cursor: onclick="{onToggle}"><typography variant="subtitle1" fontweight="medium">
{title}
</typography><iconbutton size="small" sx="{{" transform: expanded : transition:><expandmoreicon></expandmoreicon></iconbutton></stack>
{/* Content — 用 visibility + height 过渡,不卸载 DOM */}
<stack component="div" sx="{{" height: expanded : visibility: overflow: transition: ease-in-out>
{children}
</stack></paper>
);
};
export default CustomAccordion;
2. 主组件重构:使用受控展开状态 + 修复 onDragEnd
import * as React from 'react';
import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd';
import { Paper, Stack, Typography } from '@mui/material';
import CustomAccordion from './CustomAccordion'; // 引入上一步组件
// ...(保持 accordionData 不变)
const BasicButtons = () => {
const [arrayData, setArrayData] = React.useState(accordionData);
const [expandedIds, setExpandedIds] = React.useState<set>>(new Set([1])); // 默认展开第一个
const toggleAccordion = (id: number) => {
setExpandedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const onDragEnd = (result: DropResult) => {
const { source, destination } = result;
// ✅ 关键:destination 为 null 表示未 drop 到有效区域(如拖出容器),直接返回
if (!destination) return;
// 源和目标在同一列表内(本需求要求)→ 仅允许 intra-list 拖拽
if (source.droppableId !== destination.droppableId) return;
const accordionId = parseInt(source.droppableId, 10);
const sourceIndex = source.index;
const destIndex = destination.index;
setArrayData(prev =>
prev.map(accordion => {
if (accordion.id !== accordionId) return accordion;
const newContent = Array.from(accordion.content);
const [movedItem] = newContent.splice(
sourceIndex > destIndex ? sourceIndex + 1 : sourceIndex,
1
);
// 插入时需校正索引(因已删除一项)
const insertIndex = destIndex > sourceIndex ? destIndex - 1 : destIndex;
newContent.splice(insertIndex, 0, movedItem);
return { ...accordion, content: newContent };
})
);
};
return (
<dragdropcontext ondragend="{onDragEnd}"><stack spacing="{2}" sx="{{" maxwidth: mx: px:>
{arrayData.map((data) => (
<droppable key="{data.id}" droppableid="{data.id.toString()}">
{(provided) => (
<customaccordion title="{data.title}" expanded="{expandedIds.has(data.id)}" ontoggle="{()"> toggleAccordion(data.id)}
>
{/* ✅ Droppable 内容始终挂载,ref 和 props 稳定 */}
<stack ref="{provided.innerRef}" spacing="{1}" p="{2}">
{data.content.map((item, index) => (
<draggable key="{item.id}" draggableid="{item.id.toString()}" index="{index}">
{(provided) => (
<paper ref="{provided.innerRef}" sx="{{" p: bgcolor: border: solid bordercolor: cursor:><typography>{item.text}</typography></paper>
)}
</draggable>
))}
{provided.placeholder}
</stack></customaccordion>
)}
</droppable>
))}
</stack></dragdropcontext>
);
};
export default BasicButtons;</set>
⚠️ 注意事项与最佳实践
- 禁止跨列表拖拽? 本例通过 if (source.droppableId !== destination.droppableId) return 显式禁用,符合业务需求;若需支持跨列表,请确保 droppableId 全局唯一且 onDragEnd 正确处理源/目标列表索引。
- 性能优化:10×10 列表规模较小,无需虚拟滚动;但若单列表项超 50+,建议结合 react-window 或 FixedSizeList 包裹 Droppable 内容。
- 无障碍支持:自定义 Accordion 需手动添加 aria-expanded、aria-controls 等属性,MUI 版本已内置,迁移时请补全。
- 样式过渡:height: auto 无法直接 CSS 过渡,故采用 visibility + max-height 替代方案更稳妥(示例中简化为 height: auto / 0,生产环境推荐 max-height: 500px / 0 + overflow: hidden)。
✅ 总结
react-beautiful-dnd 的多列表拖拽失效,90% 源于外部 UI 库(如 MUI Accordion、Tabs)的条件渲染导致 Droppable 卸载。根本解法不是重写 onDragEnd,而是保证每个 Droppable 的 DOM 节点持久存在。通过轻量自定义 Accordion 替代方案,既规避了框架限制,又完全保留了 dnd 的声明式体验。此模式同样适用于 Tabs、Drawer、Modal 等任何可能卸载子树的容器组件。











