实现 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组件的拖拽排序功能

相关推荐
轻动琴弦13 分钟前
nestjs+webpack打包成一个mainjs
前端·webpack·node.js
m0_7482361121 分钟前
前端怎么预览pdf
前端·pdf
快乐牛牛不要困难21 分钟前
前端将base64转pdf页面预览
前端
m0_7482336431 分钟前
Python Flask Web框架快速入门
前端·python·flask
凉辰34 分钟前
使用FabricJS对大图像应用滤镜(巨坑)
前端
梓沂38 分钟前
pom.xml中dependencyManagement的作用
xml·java·前端
m0_748250031 小时前
前端pdf预览方案
前端·pdf·状态模式
neeef_se1 小时前
【Linux】WG-Easy:基于 Docker 和 Web 面板的异地组网
linux·前端·docker
stormsha1 小时前
解决 npm 安装慢的问题:加速 npm 包下载的实用方法
前端·npm·node.js
lulu_06321 小时前
safari 浏览器输入框 focus时不显示那一闪一闪的图标
前端·css·vue·safari·element-plus