实现 Table 多层级结构拖拽排序

一、背景

根据需求,需要对多层级的Table结构做拖拽排序处理,支持跨层级拖拽排序;

要求界面功能简洁,一目了然,无明显卡顿。

根据需求查看市面上常见的拖拽方案

  1. ant-design table方案

    1. 使用dnd-kit完成,例子主要是同层级排序
  2. ant-design tree方案

样式上较为满足,缺点是需求是Table结构

贴一张最终实现效果图参考

二、需求细节拆分

有几个关键点先明确:

名词解释

  • 拖拽元素------当前手拿起来的元素,通常使用drag dragId 等命名,或方法中带有drag字样

  • 目标元素------放置元素,移动或手放开时底下的元素,通常使用drop dropId等命名,或方法中带有drop字样

  • 放置类型------放置的位置距离放置元素的坐标,比如放置在目标元素上方、子元素、下方等

  • 拖动类型------目前业务中有多种拖拽功能,其中有的部分希望同层级拖动,有的则希望跨层级拖动,使用drag === 'all'代表跨层级拖动drag === 'both'代表只支持同层级拖动

拖拽方法

  • onDragStart:当用户开始对一个可拖拽元素进行拖拽操作时触发。

  • onDrag:在元素被拖拽过程中持续触发,即从 dragstart 事件触发后,只要鼠标还在移动且保持拖拽状态,就会不断触发 drag 事件。

  • onDragEnter:当被拖拽元素进入一个有效的放置目标区域时触发。

  • onDragOver:当被拖拽元素在放置目标区域内移动时触发。

  • onDragLeave:被拖拽的元素离开一个有效的拖放目标元素时触发。

  • onDrop:当用户在有效的放置目标区域松开鼠标,完成拖拽操作时触发。

需求点拆分以及代码实现

  • 当前元素如果已经展开,拖拽时应将该元素收起,否则会造成拖拽到自身或自身子元素上
typescript 复制代码
onDragStart: (event: any) => {
  const tempDragId = event.target.getAttribute('data-row-key');
  setDragId(tempDragId);
 // if (drag === 'all') {
 //   const d = listTreeMemo[temp];
 //   if (d.children) {
      onExpand();
 //   }
  }
},
  • 移动到目标元素上,需要展示蓝色的线,我们Table行高为58,分析后拆分底部20px放置在bottom,小于35px放置在body上,其余部分放置在top上;如果一移动到中间部分就展开子元素,体验很差,需要延迟2秒后展开目标元素;使用css添加蓝色的标识线
typescript 复制代码
onDragOver: (event: any) => {
  event.preventDefault();
  const nodes = findTrElement(event.target);
  const drogId = nodes.getAttribute('data-row-key')!;
  if (dragId === drogId) {
    return;
  }
  if (event.nativeEvent.layerY < 20) {
    clearTimeout(tempTimeRef.current);
    tempTimeRef.current = null;
    nodes.classList.remove('body', 'bottom');
    nodes.classList.add('parent-element', 'top');
  } else if (event.nativeEvent.layerY < 35) {
    if (drag === 'all') {
      const d = listTreeMemo[drogId];
      if (d.children && !tempTimeRef.current) {
        // 2s后展开
        tempTimeRef.current = setTimeout(() => {
          onExpand(true, d);
        }, 2000);
      }
      nodes.classList.add('parent-element', 'body', 'bottom');
    } else {
      nodes.classList.add('parent-element', 'bottom');
    }
    nodes.classList.remove('top');
  } else {
    clearTimeout(tempTimeRef.current);
    tempTimeRef.current = null;
    nodes.classList.remove('top', 'body');
    nodes.classList.add('parent-element', 'bottom');
  }
},
less 复制代码
  tr.parent-element {
    position: relative;
    &::after {
      content: '';
      position: absolute;
      left: 0;
      height: 1px;
      background-color: #1677ff;
      z-index: 20;
      width: 100%;
    }
    &.top::after {
      top: 1px;
    }
    &.bottom::after {
      bottom: 1px;
    }
  }
  • 拖拽移动:如果表格元素过多会导致出现滚动条,拖拽到最下方或者最上方希望能自动滚动

注意要做节流

typescript 复制代码
onDrag: (event: any) => {
  scrollRun(event);
}

export function getOffsetTop(element: HTMLElement | null) {
  let tempElement = element;
  let offsetTop = 0;
  while (tempElement) {
    offsetTop += tempElement.offsetTop;
    tempElement = tempElement.offsetParent as HTMLElement;
  }
  return offsetTop;
}

const { run: scrollRun } = useThrottleFn(
(event: any) => {
  if (!contentRef.current) {
    return;
  }
  const offsetTop = offsetTopRef.current;
  const offsetY = event.clientY - offsetTop;
  // 滚动元素
  const scrollDom = contentRef.current?.querySelector('.scrollbar')
    ?.firstChild as HTMLElement;
  if (!scrollDom) {
    return;
  }
  // 表头高度 54
  if (offsetY - 54 < 0) {
    scrollDom.scrollTop -= 30;
  } else if (offsetY >= contentRef.current.clientHeight) {
    scrollDom.scrollTop += 30;
  }
},
{
  wait: 100,
  leading: true,
},
);
  • 放下元素

    • 需要获取dragId和dropId,以及放置类型
    • 如果类型为body,放置到该元素的子元素,那么直接把拖拽元素移动到目标元素的children第一个
    • top或者bottom,移动到目标元素的上方或下方

由于我们实现是通过接口实现的,后端接收的参数是数组,只需要id和sortIndex即可,前端根据后端的入参调整对应的数据传输即可

typescript 复制代码
  const handleDrop = async (
    {
      dropId,
      dragId,
    }: {
      dropId: string;
      dragId: string;
    },
    type: 'top' | 'body' | 'bottom',
  ) => {
    if (dropId === dragId) {
      return;
    }
    let result: any[] = [];
    if (type === 'body' && drag === 'all') {
      const parentItem = listTreeMemo[dropId];

      const tempItem = listTreeMemo[dragId];
      if (parentItem.children?.length) {
        result = [tempItem, ...parentItem.children];
      } else {
        result = [tempItem];
      }
      result = result.map((v, index) => ({
        ...v,
        sortIndex: index,
        id: v.id,
        parentId: dropId || '',
      }));
    } else {
      const dropParentId = listTreeMemo[dropId]?.parentId;
      const dragIndex = listTreeMemo[dragId]?.index;

      const tempList = dropParentId
        ? [...listTreeMemo[dropParentId].children]
        : [...list];

      const draggedItem = listTreeMemo[dragId];

      if (`${tempList[dragIndex]?.id}` === `${dragId}`) {
        tempList.splice(dragIndex, 1);
      }

      const spliceIndex = tempList.findIndex(v => `${v.id}` === `${dropId}`);

      if (type === 'top') {
        tempList.splice(spliceIndex, 0, draggedItem);
      } else {
        tempList.splice(spliceIndex + 1, 0, draggedItem);
      }

      result = tempList.map((v, index) => ({
        ...v,
        sortIndex: index,
        id: v.id,
        parentId: dropParentId || '',
      }));
    }
    setDragId('');
    await handleDrag(result);

    updateTableData();
  };
  • render结构

目前我们用drag 来判断该表格是否开启拖拽模式

typescript 复制代码
<Table onRow={drag ? onRow : undefined} /*{...省略其他业务代码}*/ />

三、代码参考

从业务代码中截取部分代码参考

typescript 复制代码
  const [dragId, setDragId] = useState('');

  const tempTimeRef = useRef<any>(null);

  const offsetTopRef = useRef(0);

  const handleDrop = async (
    {
      dropId,
      dragId,
    }: {
      dropId: string;
      dragId: string;
    },
    type: 'top' | 'body' | 'bottom',
  ) => {
    if (dropId === dragId) {
      return;
    }
    let result: any[] = [];
    if (type === 'body' && drag === 'all') {
      const parentItem = listTreeMemo[dropId];

      const tempItem = listTreeMemo[dragId];
      if (parentItem.children?.length) {
        result = [tempItem, ...parentItem.children];
      } else {
        result = [tempItem];
      }
      result = result.map((v, index) => ({
        ...v,
        sortIndex: index,
        id: v.id,
        parentId: dropId || '',
      }));
    } else {
      const dropParentId = listTreeMemo[dropId]?.parentId;
      const dragIndex = listTreeMemo[dragId]?.index;

      const tempList = dropParentId
        ? [...listTreeMemo[dropParentId].children]
        : [...list];

      const draggedItem = listTreeMemo[dragId];

      if (`${tempList[dragIndex]?.id}` === `${dragId}`) {
        tempList.splice(dragIndex, 1);
      }

      const spliceIndex = tempList.findIndex(v => `${v.id}` === `${dropId}`);

      if (type === 'top') {
        tempList.splice(spliceIndex, 0, draggedItem);
      } else {
        tempList.splice(spliceIndex + 1, 0, draggedItem);
      }

      result = tempList.map((v, index) => ({
        ...v,
        sortIndex: index,
        id: v.id,
        parentId: dropParentId || '',
      }));
    }
    setDragId('');
    await handleDrag(result);

    updateTableData();
  };

  useEffect(() => {
    if (contentRef.current) {
      offsetTopRef.current = getOffsetTop(contentRef.current);
    }
  }, []);

  const { run: scrollRun } = useThrottleFn(
    (event: any) => {
      if (!contentRef.current) {
        return;
      }
      const offsetTop = offsetTopRef.current;
      const offsetY = event.clientY - offsetTop;
      const scrollDom = contentRef.current?.querySelector('.scrollbar')
        ?.firstChild as HTMLElement;
      if (!scrollDom) {
        return;
      }
      if (offsetY - 54 < 0) {
        scrollDom.scrollTop -= 30;
      } else if (offsetY >= contentRef.current.clientHeight) {
        scrollDom.scrollTop += 30;
      }
    },
    {
      wait: 100,
      leading: true,
    },
  );

  
  const onRow = () => ({
    draggable: true,
    style: { cursor: 'move' },
    onDrag: (event: any) => {
      scrollRun(event);
    },
    onDragStart: (event: any) => {
      const temp = event.target.getAttribute('data-row-key');
      setDragId(temp);
      if (drag === 'all') {
        const d = listTreeMemo[temp];
        if (d.children) {
          onExpand(false, d);
        }
      }
    },
    onDragEnter: (event: any) => {
      const nodes = findTrElement(event.target);
      const drogId = nodes.getAttribute('data-row-key')!;
      if (dragId === drogId) {
        return;
      }
      nodes.classList.add('parent-element', 'bottom');
    },
    onDragOver: (event: any) => {
      event.preventDefault();
      const nodes = findTrElement(event.target);
      const drogId = nodes.getAttribute('data-row-key')!;
      if (dragId === drogId) {
        return;
      }
      if (event.nativeEvent.layerY < 20) {
        clearTimeout(tempTimeRef.current);
        tempTimeRef.current = null;
        nodes.classList.remove('body', 'bottom');
        nodes.classList.add('parent-element', 'top');
      } else if (event.nativeEvent.layerY < 35) {
        if (drag === 'all') {
          const d = listTreeMemo[drogId];
          if (d.children && !tempTimeRef.current) {
            // 2s后展开
            tempTimeRef.current = setTimeout(() => {
              onExpand(true, d);
            }, 2000);
          }
          nodes.classList.add('parent-element', 'body', 'bottom');
        } else {
          nodes.classList.add('parent-element', 'bottom');
        }
        nodes.classList.remove('top');
      } else {
        clearTimeout(tempTimeRef.current);
        tempTimeRef.current = null;
        nodes.classList.remove('top', 'body');
        nodes.classList.add('parent-element', 'bottom');
      }
    },
    onDragLeave: (event: any) => {
      const nodes = findTrElement(event.target);
      nodes.classList.remove('parent-element', 'top', 'bottom', 'body');
      clearTimeout(tempTimeRef.current);
      tempTimeRef.current = null;
    },
    onDrop: (event: any) => {
      event.preventDefault();
      event.stopPropagation();
      clearTimeout(tempTimeRef.current);
      tempTimeRef.current = null;
      const nodes = findTrElement(event.target);
      const type =
        (nodes.classList.contains('top') && 'top') ||
        (nodes.classList.contains('body') && 'body') ||
        'bottom';
      nodes.classList.remove('parent-element', 'top', 'bottom', 'body');

      const dropId = nodes.getAttribute('data-row-key')!;
      handleDrop(
        {
          dropId,
          dragId,
        },
        type,
      );
    },
  });
  

四、参考文章

# 快速实现一个antd的table组件的拖拽排序功能

相关推荐
卸任15 小时前
Vibe coding工具使用心得
前端·ai编程
brien916 小时前
为什么模型标称 1M,Google Antigravity 聊到 256K 却突然失忆?我们给它做了个原生行内监控圆环
前端
修炼的dance16 小时前
录屏关键帧间隔多少合适?六档实测,2秒到10秒小了75.6%
前端
Lank_M16 小时前
关键帧从1秒拉到10秒,录屏小87.3%,摄像头小10.8%
前端
补码缩写16 小时前
用 ffprobe 拆录屏,八成字节在 29 个关键帧上
前端
百思不得小李16 小时前
一个复杂的动态表单是如何实现的
前端·设计模式
陈随易17 小时前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
风骏时光牛马17 小时前
前沿视界
前端
kyriewen17 小时前
我扫了 814 个依赖,97 个还敢要 Node 10
前端·javascript·程序员
陈随易17 小时前
3000 万人在用的翻译插件,重写一遍就是一门生意
前端·后端·程序员