使用react-dnd实现 antd 多级树状表格拖拽排序功能

源码连接

需求场景

  • 支持树形结构表格的拖拽排序,支持同级移动以及多级嵌套。
  • 实现同级向上、同级向下、放置到子集的排序功能,识别嵌套的不合法目标。

技术方案

antd的官方案例 案例链接 推荐了dnd这个库来实现一层表格的拖拽功能,所以我们选用react-dnd这个库,来实现这个多级的拖拽排序。react-dnd链接

我们使用自定义tabel行的方式,来操作行数据,使用useDropuseDrag来实现对于table行的拖拽以及放置功能。

typescript 复制代码
      <Table
        components={{
          body: {
            row: DragRow,//DragRow.tsx
          },
        }}
        {...antTableProps}
      />

主要关注点如下:

  1. 起始行和目标行数据id的收集。
  2. 判断拖拽元素在目标行中的位置,来展示不同的交互效果。
  3. 判断目标行所在位置的合法性,比如禁止父级元素向子集元素拖拽。
  4. 修改放置之后的数据。

拖拽排序实现

  1. 使用react-dnd需要自定义table的row,用来方便传递属性,需要自定义编写DragRow组件来接管row。把table组件使用DndProvider包裹,import { HTML5Backend } from 'react-dnd-html5-backend';PC端传入这个backend,连接适配器,我们不需要关心拖拽时候元素本身的交互细节了,
  2. 通过回调函数的方式,使用antd table的onRowapi,把moveRow函数挂载到对应的行上。
  3. 拿到拖拽节点的id,松手节点的id,放置类型(同级向上、同级向下、放置到子集),编写sortDataByMove(data,dragId,dropId,dropOverType)函数。
  4. 更新操作之后的数据,传入table。

核心代码实现如下

这个组件主要用来获取拖拽时候对于元素位置,以及操作类型的判断,比如当拖拽到目标行垂直中心点上下40%的时候,判定为插入到children,否则判断插入到上方/下方。

typescript 复制代码
import React, { useRef } from 'react';
import { useDrag, useDrop } from 'react-dnd';
import './index.less';

const type = 'DragRow';
export type DropOverType = 'upward' | 'downward' | 'inside';

export interface DragRowProps extends React.HTMLAttributes<HTMLTableRowElement> {
  id: string; //目标id
  moveRow: (dragId: string, dropId: string, type?: DropOverType) => void;
  disableDrop?: boolean;
}

const DragRow: React.FC<DragRowProps> = ({
  id,
  moveRow,
  className,
  style,
  disableDrop,
  ...restProps
}) => {
  const ref = useRef<HTMLTableRowElement>(null);
  const [dropOverType, setDropOverType] = React.useState<DropOverType>();

  const [{ isOver }, drop] = useDrop({
    accept: type,
    hover: (item, monitor) => {
      const clientOffset = monitor.getClientOffset();
      const hoverBoundingRect = ref.current?.getBoundingClientRect();

      if (clientOffset && hoverBoundingRect) {
        const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
        const hoverClientY = clientOffset.y - hoverBoundingRect.top;
        const hoverPercentage = (hoverClientY - hoverMiddleY) / hoverMiddleY;
        const getClassName = (hoverPercentage: number) => {
          const offsetValue = 0.4;
          if (hoverPercentage > offsetValue) {
            return 'downward';
          } else if (hoverPercentage < -offsetValue) {
            return 'upward';
          } else {
            return 'inside';
          }
        };
        const dropClassName = getClassName(hoverPercentage);
        setDropOverType(dropClassName);
      }
    },
    collect: (monitor) => {
      return {
        isOver: monitor.isOver(),
      };
    },

    drop: (item: { id: string }) => {
      const dragId = item.id;
      const targetId = id;
      moveRow(dragId, targetId, dropOverType);
    },
  });
  const [_, drag] = useDrag({
    type,
    item: { id },
    collect: (monitor) => {
      return {
        isDragging: monitor.isDragging(),
      };
    },
  });

  const canDrop = !disableDrop;

  if (canDrop) {
    drop(drag(ref));
  }

  return (
    <tr
      ref={ref}
      className={`${className}${isOver ? ` drop-over-${dropOverType}` : ''}`}//目标节点样式
      style={{
        cursor: canDrop ? 'move' : 'auto',
        ...style,
      }}
      {...restProps}
    />
  );
};
export default DragRow;

针对不同插入类型,展示不同的css样式提示。

less 复制代码
.drag-overlay {
  background: rgba(255, 255, 255, 0.8);
  border: 1px dashed #1890ff;
  border-radius: 4px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}

.drop-over-downward td {
  border-bottom: 2px dashed #1890ff !important;
  margin-bottom: 10px !important;
  ::after {
    content: '向下插入';
    font-size: 12px;
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    height: 2px;
  }
}

tr.drop-over-upward td {
  border-top: 2px dashed #d1d9e0;
  ::after {
    content: '向上插入';
    font-size: 12px;
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    height: 2px;
  }
}

tr.drop-over-inside td {
  background-color: #1890ff20;
}

这里主要实现了已知拖拽节点的id,目标节点的id之后,对数据按照要求进行操作。

typescript 复制代码
type DataType = {
  id?: string;
  [key: string]: any;
  children?: DataType[];
};
type DropOverType = 'upward' | 'downward' | 'inside';
type ID = string | number;
/**
 * 节点位置信息
 */
type NodeLocation = {
  node: DataType; // 当前节点
  parentArray: DataType[]; // 当前节点所在的数组
  parentNode: DataType | null; // 当前节点的父节点(如果是根节点则为null)
  index: number; // 当前节点在数组中的索引
  parentLocation: NodeLocation | null; // 父节点的位置信息(用于构建祖先链)
};

/**
 * 在树形结构中查找节点位置
 */
const findNodeLocation = (
  data: DataType[],
  id: ID,
  parentNode: DataType | null = null,
  parentArray: DataType[] = data,
  parentLocation: NodeLocation | null = null,
): NodeLocation | null => {
  for (let i = 0; i < parentArray.length; i++) {
    const node = parentArray[i];
    const currentLocation: NodeLocation = {
      node,
      parentArray,
      parentNode,
      index: i,
      parentLocation,
    };

    // 找到目标节点
    if (node.id === id) {
      return currentLocation;
    }

    // 递归搜索子节点
    if (node.children) {
      const found = findNodeLocation(node.children, id, node, node.children, currentLocation);
      if (found) return found;
    }
  }
  return null;
};
/**
 * 检查拖拽节点是否是目标节点的祖先
 */
const isAncestor = (dragLocation: NodeLocation, dropLocation: NodeLocation): boolean => {
  let parent = dropLocation.parentLocation;
  while (parent) {
    if (parent.node === dragLocation.node) {
      return true;
    }
    parent = parent.parentLocation;
  }
  return false;
};
/**
 * 移除空children属性
 */
const removeEmptyChildren = (node: DataType) => {
  if (node.children && node.children.length === 0) {
    delete node.children;
  }
};
/**
 *
 * @param data 数据源
 * @param dragId 拖拽行的id
 * @param dropId 目标行的id
 * @param dropOverType 操作类型
 * @returns
 */
export const sortDataByMove = (
  data: readonly DataType[],
  dragId: ID,
  dropId: ID,
  dropOverType: DropOverType,
): DataType[] => {
  // 1. 深拷贝原始数据
  const newData = JSON.parse(JSON.stringify(data));

  // 2. 查找节点位置
  const dragLocation = findNodeLocation(newData, dragId);
  const dropLocation = findNodeLocation(newData, dropId);

  // 3. 检查节点是否存在
  if (!dragLocation || !dropLocation) return newData;

  // 4. 检查祖先关系(拖拽节点不能是目标节点的祖先)
  if (isAncestor(dragLocation, dropLocation)) return newData;

  // 5. 从原位置移除拖拽节点
  const [dragNode] = dragLocation.parentArray.splice(dragLocation.index, 1);

  // 6. 移除后处理原父节点的空children
  if (dragLocation.parentNode) {
    removeEmptyChildren(dragLocation.parentNode);
  }

  // 7. 根据放置类型处理
  switch (dropOverType) {
    case 'inside': {
      // 放置到目标节点内部
      if (!dropLocation.node.children) {
        dropLocation.node.children = [];
      }
      dropLocation.node.children.push(dragNode);
      break;
    }

    case 'upward':
    case 'downward': {
      // 调整目标节点索引(当同一层级移动且拖拽节点在目标节点之前时)
      let adjustedIndex = dropLocation.index;
      if (
        dragLocation.parentArray === dropLocation.parentArray &&
        dragLocation.index < dropLocation.index
      ) {
        adjustedIndex -= 1;
      }

      // 计算插入位置
      const insertIndex = dropOverType === 'upward' ? adjustedIndex : adjustedIndex + 1;

      // 插入到目标位置
      dropLocation.parentArray.splice(insertIndex, 0, dragNode);
      break;
    }
  }

  return newData;
};

通过以上的组件以及工具函数,最终实现功能:

typescript 复制代码
import { Table, TableProps } from 'antd';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import DragRow, { DragRowProps, DropOverType } from './DragRow';
import { sortDataByMove } from './utile';

export interface SortableTreeTableProps extends Omit<TableProps, 'components'> {
  // 由于涉及到操作数据,rowKey为必填项
  rowKey: string | ((record: Record<string, any>) => string);
  onDragEnd: (
    dragEndData: Record<string, any>,
    dragId: string,
    dropId: string,
    dropOverType: DropOverType,
  ) => void;
}
export const SortableTreeTable: React.FC<SortableTreeTableProps> = ({
  onDragEnd,
  ...antTableProps
}) => {
  const dataSource = [
    {
      id: '1',
      name: '类别-1',
      children: [
        {
          id: '1-1',
          name: '类别-1-1',
        },
        {
          id: '1-2',
          name: '类别-1-2',
          children: [
            {
              id: '1-2-1',
              name: '类别-1-2-1',
            },
          ],
        },
      ],
    },
    { id: '2', name: '类别-2' },
    { id: '3', name: '类别-3' },
    { id: '4', name: '类别-4' },
    { id: '5', name: '类别-5' },
    { id: '6', name: '类别-6' },
    { id: '7', name: '类别-7' },
    { id: '8', name: '类别-8' },
    { id: '9', name: '类别-9' },
  ];
  const [dropEndData, setDropEndData] = useState<Record<string, any>[]>(dataSource);

  const moveRow = (dragId: string, dropId: string, dropOverType?: DropOverType) => {
    if (dragId === dropId || !dropOverType) return;
    setDropEndData((d) => {
      const newData = sortDataByMove(d, dragId, dropId, dropOverType);
      return newData;
    });
  };

  return (
    <DndProvider backend={HTML5Backend}>
      <Table
        dataSource={dropEndData}
        pagination={false}
        components={{
          body: {
            row: DragRow,
          },
        }}
        onRow={(record: Record<string, any>, index) => {
          const attr: DragRowProps = {
            id: String(record?.id ?? index),
            moveRow,
          };
          return attr;
        }}
        {...antTableProps}
      />
    </DndProvider>
  );
};
相关推荐
小小小小宇41 分钟前
前端Loader笔记
前端
烛阴2 小时前
从0到1掌握盒子模型:精准控制网页布局的秘诀
前端·javascript·css
前端工作日常5 小时前
我理解的`npm pack` 和 `npm install <local-path>`
前端
李剑一5 小时前
说个多年老前端都不知道的标签正确玩法——q标签
前端
嘉小华5 小时前
大白话讲解 Android屏幕适配相关概念(dp、px 和 dpi)
前端
姑苏洛言5 小时前
在开发跑腿小程序集成地图时,遇到的坑,MapContext.includePoints(Object object)接口无效在组件中使用无效?
前端
奇舞精选5 小时前
Prompt 工程实用技巧:掌握高效 AI 交互核心
前端·openai
Danny_FD6 小时前
React中可有可无的优化-对象类型的使用
前端·javascript
用户757582318556 小时前
混合应用开发:企业降本增效之道——面向2025年移动应用开发趋势的实践路径
前端
P1erce6 小时前
记一次微信小程序分包经历
前端