React中如何实现嵌套侧边栏列表子项的拖拽排序功能
分区侧边栏同区拖拽排序实现
核心逻辑
嵌套列表拖拽失效的核心原因是未做分区边界判定,跨分区拖拽时排序逻辑错乱。基于原生HTML5拖拽API实现即可,不需要引入额外第三方库,核心规则如下:
- 仅给分区内的菜单项添加
draggable属性,外层分区容器不绑定任何拖拽属性,从根源禁止分区本身被拖拽 - 拖拽启动时记录当前拖拽项所属的分区索引、自身在分区菜单项中的索引
- 拖拽悬浮到其他菜单项时,先判断悬浮项所属分区是否和拖拽源分区一致,跨分区直接阻止放置逻辑
- 放置时仅在同分区内做菜单项数组的重排,遵循React不可变数据原则更新状态
- 拖拽过程增加基础视觉反馈,提升交互体验
完整实现代码
状态定义
const [sidebar, setSidebar] = React.useState(sidebarSections) // 拖拽源信息 const [dragInfo, setDragInfo] = React.useState({ sectionIndex: null, itemIndex: null, }) // 拖拽悬浮目标信息 const [dragOverInfo, setDragOverInfo] = React.useState({ sectionIndex: null, itemIndex: null, })
拖拽事件处理函数
const handleDragStart = (e, sectionIdx, itemIdx) => { setDragInfo({ sectionIndex: sectionIdx, itemIndex: itemIdx }) e.dataTransfer.effectAllowed = 'move' } const handleDragOver = (e, sectionIdx, itemIdx) => { e.preventDefault() // 跨分区直接跳过,不响应放置 if (sectionIdx !== dragInfo.sectionIndex) return setDragOverInfo({ sectionIndex: sectionIdx, itemIndex: itemIdx }) } const handleDrop = (e, sectionIdx, itemIdx) => { e.preventDefault() // 跨分区/拖到原位置不做处理 if (sectionIdx !== dragInfo.sectionIndex || dragInfo.itemIndex === itemIdx) { setDragInfo({ sectionIndex: null, itemIndex: null }) setDragOverInfo({ sectionIndex: null, itemIndex: null }) return } setSidebar(prev => { const newSidebar = structuredClone(prev) const sectionItems = newSidebar[sectionIdx].elements // 重排数组 const [draggedItem] = sectionItems.splice(dragInfo.itemIndex, 1) sectionItems.splice(itemIdx, 0, draggedItem) return newSidebar }) // 重置拖拽状态 setDragInfo({ sectionIndex: null, itemIndex: null }) setDragOverInfo({ sectionIndex: null, itemIndex: null }) } const handleDragEnd = () => { // 拖拽中途取消也重置状态 setDragInfo({ sectionIndex: null, itemIndex: null }) setDragOverInfo({ sectionIndex: null, itemIndex: null }) }
渲染逻辑(修正原代码问题)
const list = () => ( <Box sx={{ width: anchor === 'top' || anchor === 'bottom' ? 'auto' : 250, }} role="presentation" onKeyDown={toggleDrawer(anchor, false)} > <List> {/* 原代码遍历的是常量sidebarSections,改为遍历状态值sidebar,否则更新不生效 */} {sidebar.map((section, sectionIdx) => ( <Box key={section.name} sx={{ marginTop: 2 }} > {section.name ? ( <ListItemText sx={{ px: 2, color: 'primary.light', }} > {section.name} </ListItemText> ) : null} <List> {section.elements.map((subsection, itemIdx) => subsection.show || settings ? ( <ListItem key={subsection.name} draggable onDragStart={(e) => handleDragStart(e, sectionIdx, itemIdx)} onDragOver={(e) => handleDragOver(e, sectionIdx, itemIdx)} onDrop={(e) => handleDrop(e, sectionIdx, itemIdx)} onDragEnd={handleDragEnd} sx={{ opacity: dragInfo.sectionIndex === sectionIdx && dragInfo.itemIndex === itemIdx ? 0.5 : 1, borderTop: dragOverInfo.sectionIndex === sectionIdx && dragOverInfo.itemIndex === itemIdx ? '2px solid' : 'none', borderColor: 'primary.main', cursor: 'grab', '&:active': { cursor: 'grabbing' } }} > {settings ? ( <Checkbox checked={subsection.show} onChange={() => { // 原代码直接修改状态对象,改为不可变更新 setSidebar(prev => { const newSidebar = structuredClone(prev) newSidebar[sectionIdx].elements[itemIdx].show = !newSidebar[sectionIdx].elements[itemIdx].show return newSidebar }) }} /> ) : null} <Link href={section.routePrefix + subsection.href} passHref> <Button sx={{ color: router.pathname.includes( `${section.routePrefix + subsection.href}` ) ? "secondary.main" : "primary.main", width: "100%", justifyContent: "start" }} startIcon={subsection.icon} > {subsection.name} </Button> </Link> </ListItem> ) : null )} </List> <Divider sx={{color: 'primary.light'}} /> </Box> ))} </List> </Box> )
补充说明
structuredClone是浏览器原生深拷贝方法,如果需要兼容极低版本浏览器,可以替换为JSON.parse(JSON.stringify())- 拖拽样式可以根据需求自行调整,比如增加拖拽占位符、拖拽项阴影等
- 逻辑上完全阻断跨分区拖拽,菜单项只能在所属分区内调整顺序,符合需求
内容的提问来源于stack exchange,提问作者dokichan
相关产品推荐
相关产品推荐

