You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.28 08:57:20