dnd-kit 实现拖拽 以及判别同组/跨组拖拽

2,386 阅读8分钟

dnd-kit 拖拽、判别 同组/跨组 拖拽

dnd-kit 简介

dnd-kit 中暴露了四个监听拖拽行为的钩子函数,分别是

  • onDragStart

  • onDragOver

  • onDragEnd

  • onDragCancel

完成一次拖拽,大致分为三个阶段:点按鼠标选中元素【 onDragStart 】、拖动元素到目标位置【 onDragOver 】、松开鼠标放下元素【 onDragEnd 】

还有一种情况就是:选中鼠标后,不拖动元素 或者 拖动距离有限 的情况下,直接松开鼠标,此时元素回到原位,同时触发【 onDragCancel 】事件

其中我们主要通过 onDragOver 和 onDragEnd 这两个函数来实现目标功能

数据结构要求

dnd-kit 中,每个项(TSingleSoft)中都必须要有 id 属性

type TSingleSoft = {
  id: string, 
  weight: number  // 软件权重,通过这个字段决定软件排序,由后端计算
}
type TSoftGroup = Record<string, TSingleSoft>

// 示例
const softGroups: TSoftGroup = {
	groupOne: [{id:'soft-a', name: 'a', weight: 1}, {id: 'soft-b', name: 'b', weight: 2}]
  groupTwo: [{id:'soft-c', name: 'c', weight: 4}, {id: 'soft-d', name: 'd', weight: 3}]
}

总体设计

拖拽的本质是 改变 数据结构中数据项的位置后,再重新渲染视图

  1. 我们需要 维护一个 TSoftGroup 类型的数据对象,后续统称为 softGroups(首次加载时从后端获取)

  2. 前端视图层的渲染只基于 前端存储的 softGroups,但是在进入页面,或者刷新页面时,都会向后端发送请求来更新 softGroups

  3. 将获取到的 softGroups 存为前端状态,此时,相当于前后端各自保存了一份 softGroups ,且他们记录的信息完全一致

  4. 执行一次拖拽操作,成功交换两个元素位置。此时前端的 softGroups 已经更新,但后端的 softGroups 还没有更新

  5. 所以我们需要在每次拖拽后,将本次更改的信息传给后端(拖拽元素的 id 、weight),后端根据这些信息去更新 服务器中的 softGroups,以便和前后端保持一致

可优化之处

  • 可以先判断元素是否真正地和其他元素交换了位置(将元素选中拖拽后,立马松开鼠标或者将其放回原处,所以实际上此时元素未移动),如果元素实际上未移动,便不用向后端发送请求更新 softGroups

遇见的问题

由于 softGroups 是引用类型,且拖拽过程中,dnd-kit 抛出来的 hook 的调用顺序分别为:

onDragStart - onDragOver - onDragEnd - onDragCancel

下列现象详细解释起来缘由过多,所以只需知道最终有这么些问题即可

  1. onDragOver 、 onDragEnd 中暴露出来的 active、over 等参数都是引用,会互相影响且边界情况很多。example:当目标分组是空分组时,over.id的值会是分组的id
  2. 拖拽元素的过程中(onDragOver),我们为了视图上的过渡动画效果,此时就已经更改了 softGroups
  3. onDragOver 和 onDragEnd 两个钩子函数暴露出的都是 softGroups 的引用,然而在  onDragOver 中,我们已经改变了 softGroups, 所以执行到 onDragEnd 的时候,该函数内的抛出的 softGroups [新] 就已经不是 onDragStart 时的 softGroups [旧] 了。这也就意味着此时我们在此处已经无法通过 比对新旧 softGroups 来得到被 排挤开的元素 的 id 以及 本次拖拽是同组拖拽还是跨组拖拽了

解决思路

  • 在 onDragStart 中,将 softGroups 深克隆一份, 在 onDragEnd 时,将 softGroups 重置

  • 在 onDragOver 中根据本次拖拽操作(不论是否跨组)修改 softGroups 中的对应的数据项,然后重新渲染视图 (更改前端 softGroups)

  • 只在 onDragEnd 中判断是否跨组 并且 发送请求 更新后端的 softGroups

    • 通过 onDragEnd 钩子中暴露的参数 active(当前拖拽项的信息),拿到 active.id 分别去 深克隆的 softGroups [旧]  以及 softGroups [新] 里分别找到 新旧 groupId。如果新旧 groupId 一致,说明是同组拖拽,反之则为跨组拖拽

Dnd-kit 使用

dnd-kit 官方文档地址: docs.dndkit.com/presets/sor…

dnd-kit 运作有以下几个条件:

  • 拖拽上下文  DndContext
  • 可放置区域  Droppable  使用 Dnd-kit 中的 useDroppable 创建
  • 可拖拽元素  Draggable  使用 Dnd-kit 中的 useDraggable 创建

不过我们需要排序,所以需要在 DndContext 下再添加一个排序上下文,然后使用 useSortable 替代 useDraggable 即可,最终的结构如下

<DndContext>
	<Droppable />
</DndContext>

// Droppable.tsx
<SortableContext>
  <SoftCard />
</SortableContext>
main.tsx
import {
  closestCenter,
  CollisionDetection,
  DndContext,
  // DragOverlay,
  KeyboardSensor,
  MouseSensor,
  rectIntersection,
  TouchSensor,
  UniqueIdentifier,
  useSensor,
  useSensors,
} from '@dnd-kit/core';
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';

import { cloneDeep } from 'lodash';
import { useThrottle, useThrottleFn } from 'ahooks';

type TReflect_id2softList = Record<string, TSingleSoft[]>;
type TReflect_id2groupName = Record<string, string>;

const HandleSoft = () => {
  const {
    setSoftGroupList,
  } = useModel('softModel');


  const [isListAreaLoading, setIsListAreaLoading] = useState(false);
  const [itemGroups, setItemGroups] = useState<TReflect_id2softList>({});
  const [Reflect_id2groupName, setReflect_id2groupName] = useState<TReflect_id2groupName>({});

  const recentlyMovedToNewContainer = useRef(false);
  const lastOverId = useRef<UniqueIdentifier | null>(null);
  const [activeId, setActiveId] = useState<string | null>(null);
  const [clonedItems, setClonedItems] = useState<TReflect_id2softList | null>(null);

  // 拖拽传感器
  const sensors = useSensors(
    useSensor(MouseSensor, {
      activationConstraint: {
        distance: 1, // 拖拽激活阈值,单位px,如果不设置一下的话,用户点击都能触发拖拽逻辑
      },
    }),
    useSensor(TouchSensor),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    }),
  );

  const handleDragStart = ({ active }: any) => {
    setActiveId(active.id);
    const deepCopy = cloneDeep(itemGroups);
    setClonedItems(deepCopy);
  };
  // 触发情况很少
  const handleDragCancel = () => {
    console.log('%c 拖拽取消----====------', 'color: red');
    if (clonedItems) {
      setItemGroups(clonedItems);
    }
    setActiveId(null);
    setClonedItems(null);
  };

  const { run: handleDragOver } = useThrottleFn(
    ({ active, over }) => {
      // console.log('%c 拖拽中 ...... activeID', 'color: purple', active.id);
      // console.log('%c 拖拽中 ...... coverID>>>>>>>', 'color: purple', over.id);
      const overId = over?.id;
      if (!overId || active.id in itemGroups) {
        return;
      }
      const activeContainer = findContainer(active.id);
      const overContainer = findContainer(overId);

      if (!overContainer || !activeContainer) {
        return;
      }
      // console.log('%cactiveC<<<<<<<<', 'color: #eea24b', activeContainer);
      // console.log('%coverCon >>>>>>', 'color: #eea24b', overContainer);
      if (activeContainer !== overContainer) {
        // console.log('%c跨组了>>>>>>>>', 'color: red');
        setItemGroups((items) => {
          const curAC = items[activeContainer];
          const curOC = items[overContainer];
          const activeIndex = curAC.findIndex((soft) => soft.id === active.id);
          let overIndex = curOC.findIndex((soft) => soft.id === overId);
          // 小于0 是移动到了空的 新分组 , 否则是移动到了非空的 新分组
          if (overIndex === -1) {
            // 分组,overId 就是当前分组的id
            if (overId in items) {
              overIndex = items[overId].length;
            }
          } else {
            overIndex = curOC.findIndex((soft) => soft.id === overId);
          }
          recentlyMovedToNewContainer.current = true;

          const s = curAC[activeIndex];
          if (!s) {
            console.log(
              '%cconst s = curAC.splice(activeIndex, 1)[0]; 》》》》中 s 为 undefined了>>>>',
              'color: red',
            );
            return clonedItems as TReflect_id2softList;
          }
          return {
            ...items,
            // [activeContainer]: curAC,
            [activeContainer]: [...curAC.slice(0, activeIndex), ...curAC.slice(activeIndex + 1)],
            [overContainer]: [...curOC.slice(0, overIndex), s, ...curOC.slice(overIndex)],
          };
        });
      } else {
        setItemGroups((groups) => {
          const curContainerList = groups[activeContainer];
          const activeIndex = curContainerList.findIndex((item) => item.id === active.id);

          const overIndex = curContainerList.findIndex((item) => item.id === overId);

          const s = curContainerList.splice(activeIndex, 1)[0];
          curContainerList.splice(overIndex, 0, s);

          return {
            ...groups,
            // splice 不改变原数组,所有这儿要解构一下,不然视图不会更新
            [activeContainer]: [...curContainerList],
          };
        });
      }
    },
    { wait: 100 },
  );


  const updateSoftwareSort = async (
    activeSoftId: string,
    curActiveContainerId: string,
    itemGroups: TReflect_id2softList,
  ): Promise<void> => {
    try {
      if (!itemGroups) {
        console.log('%c updateSoftwareSort 入参 itemGroups 不能为空', 'color: red');
        return;
      }
      const activeSoftIndex = itemGroups[curActiveContainerId].findIndex(
        (item) => item.id === activeSoftId,
      );

      if (activeSoftIndex === -1) {
        throw new Error('updateSoftwareSort 函数中, 没有找到activeSoftIndex');
      }

      const activeSoft = itemGroups[curActiveContainerId][activeSoftIndex];
      const overSoft = itemGroups[curActiveContainerId][activeSoftIndex + 1];
      // 有可能是跨组拖动到新分组的最后一个,此时overSoft为undefined
      const aim = overSoft ? { id: overSoft.id, weight: overSoft.weight } : null;

      const reqBody: {
        current: { id: string; weight: number };
        aim: { id: string; weight: number } | null;
      } = {
        current: {
          id: activeSoft.id,
          weight: activeSoft.weight,
        },
        aim,
      };
      reqUpdateSoftSort(reqBody);
    } catch (error) {
      console.log('%c 更新软件排序失败', 'color: red', error);
    }
  };

  const handleDragEnd = async ({ active, over }: any) => {
    try {
      // console.log('%cactive', 'color: #eea24b', active);
      // console.log('%cover', 'color: #eea24b', over);
      if (active.id in itemGroups && over?.id) {
        console.log('%c未触发情况一:::', 'color: blue', 'active.id in itemGroups && over?.id');
        return;
      }
      // const activeContainer = findContainer(active.id);

      const preActiveContainerId = findContainerInClone(active.id);
      const curActiveContainerId = findContainer(active.id);

      if (!curActiveContainerId) {
        setActiveId(null);
        setItemGroups(clonedItems!);
        return;
      }
      const overId = over?.id;
      if (!overId) {
        setActiveId(null);
        setItemGroups(clonedItems!);
        return;
      }
      console.log('%c preActiveContainerId', 'color: purple', preActiveContainerId);
      console.log('%c curActiveContainerId', 'color: purple', curActiveContainerId);

      if (preActiveContainerId !== curActiveContainerId) {
        /* *********************跨组情况********************** */
        console.log('%c 跨组拖拽', 'color: red');
        await reqUpdateSoftGroup({
          applicationId: active.id,
          classifyId: curActiveContainerId,
        });
        // console.log('更新软件分类结果,,,,,,,,,,,,,', res);

        // 假设本次拖动是:移动一个软件到新的空分组的话,由于在dragOver时就已经把这个软件设置到此新分组了,所以这儿判断的长度应该是 1 而不是 0
        const isMoveToNullGroup = itemGroups[curActiveContainerId].length === 1;
        // 如果当前分组是空分组就不没必要发起排序请求了
        if (!isMoveToNullGroup) {
          updateSoftwareSort(active.id, curActiveContainerId, itemGroups);
        }
      } else {
        /* ********************* 非跨组情况(同组移动) ********************** */
        // 同组相较于跨组比较特殊, 在handleDragEnd中改变页面视图 比较容易
        console.log('%c 非跨组>>>>>>>>>', 'color: red');
        updateSoftwareSort(active.id, curActiveContainerId, itemGroups);
      }

      setActiveId(null);
    } catch (error) {
      console.log('%c 拖拽-handleDragEnd Error:', 'color: red', error);
    }
  };


  // 碰撞检测
  const collisionDetectionStrategy: CollisionDetection = useCallback(
    (args) => {
      // Start by finding any intersecting droppable
      let overId = rectIntersection(args);

      if (activeId && activeId in itemGroups) {
        return closestCenter({
          ...args,
          droppableContainers: args.droppableContainers.filter(
            (container) => container.id in itemGroups,
          ),
        });
      }

      if (overId != null) {
        if (overId in itemGroups) {
          const containerItems = itemGroups[overId];

          // If a container is matched and it contains items (columns 'A', 'B', 'C')
          if (containerItems.length > 0) {
            // Return the closest droppable within that container
            overId = closestCenter({
              ...args,
              droppableContainers: args.droppableContainers.filter(
                (container) => container.id !== overId && containerItems.includes(container.id),
              ),
            });
          }
        }

        lastOverId.current = overId;
        return overId;
      }

      // When a draggable item moves to a new container, the layout may shift
      // and the `overId` may become `null`. We manually set the cached `lastOverId`
      // to the id of the draggable item that was moved to the new container, otherwise
      // the previous `overId` will be returned which can cause items to incorrectly shift positions
      if (recentlyMovedToNewContainer.current) {
        lastOverId.current = activeId;
      }

      // If no droppable is matched, return the last match
      return lastOverId.current;
    },
    [activeId, itemGroups],
  );

  const findContainer = (id: string) => {
    if (id in itemGroups) {
      return id;
    }
    return Object.keys(itemGroups).find((key) =>
      itemGroups[key].some((soft) => soft.id.includes(id)),
    );
  };

  const findContainerInClone = (id: string) => {
    if (!clonedItems) {
      console.log('%c findContainerInClone>>>>>> 里没有 clonedItems', 'color: #eea24b');
      return;
    }
    if (id in clonedItems) {
      return id;
    }
    return Object.keys(clonedItems).find((key) =>
      clonedItems[key].some((soft) => soft.id.includes(id)),
    );
  };
  
  useEffect(() => {
    requestAnimationFrame(() => {
      recentlyMovedToNewContainer.current = false;
    });
  }, [itemGroups]);

  return (
      <Spin spinning={isListAreaLoading}>
        <div className={curStyle.dnd_list_area}>
          <DndContext
            sensors={sensors}
            collisionDetection={collisionDetectionStrategy}
            onDragStart={handleDragStart}
            onDragCancel={handleDragCancel}
            // onDragOver={handleDragOver1}
            onDragOver={handleDragOver}
            onDragEnd={handleDragEnd}
          >
            {Object.keys(itemGroups).map((groupId) => {
              const softList = itemGroups[groupId]; 

              const groupText = Reflect_id2groupName[groupId];
              return (
                <>
                  <div className={curStyle.group_title}>
                    {/* 这儿分组中间允许包含空格,所以用<pre> */}
                    <pre style={{ margin: 0 }}>{groupText}</pre>
                    <span className={curStyle.group_list_length}>({softList.length})</span>
                  </div>
                  <Droppable id={groupId} items={softList} key={groupId} />
                </>
              );
            })}
            {/* <DragOverlay>{activeId ? <div id={activeId} dragOverlay /> : null}</DragOverlay> */}
          </DndContext>
        </div>
      </Spin>
  );
};

export default HandleSoft;
Droppable.tsx
import { useDroppable } from '@dnd-kit/core';
import { rectSortingStrategy, SortableContext } from '@dnd-kit/sortable';

import SoftCard from '../../components/SoftCard';
import { TSingleSoft } from '@/types/softManager/home';

import './Droppable.less';

type TDroppable = {
  id: string;
  items: TSingleSoft[];
};
const Droppable = (props: TDroppable) => {
  const { id, items } = props;
  const { setNodeRef } = useDroppable({ id });
  return (
    <SortableContext id={id} items={items} strategy={rectSortingStrategy}>
      <div className="droppable" ref={setNodeRef}>
        {items.map((singleSoft) => {
          return <SoftCard key={singleSoft.applicationId} {...singleSoft} />;
        })}
      </div>
    </SortableContext>
  );
};

export default Droppable;
SoftCard.tsx
import { Tooltip } from '@toft/react';
import { useModel } from 'umi';

import { TSingleSoft } from '@/types/softManager/home';
import { Reflect_status2 } from '../../Reflect';

import editIconPath from '~public/softManager/edit.svg';
import delIconPath from '~public/softManager/del.svg';

// 拖拽相关
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';

import curStyle from './index.less';
import TextOverflow from '@/components/TextOverflow';

const SoftCard = (props: TSingleSoft) => {
  const { setDelSoftModal, setEditSoftDrawer } = useModel('softModel');
  const { icon, displayName, description, applicationStatus, id } = props;

  // 拖拽相关 ===========
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
    id,
  });
  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    opacity: isDragging ? 0.5 : 1,
  };
  // ===================

  return (
    <div className={curStyle.softCard} style={style} ref={setNodeRef}>
      <div className={curStyle.left_icon} {...attributes} {...listeners}>
        <img style={{ width: '100%', height: '100%' }} src={icon} />
      </div>
      <div className={curStyle.right}>
        <div className={curStyle.right_top}>
          <div className={curStyle.right_top_draggable} {...attributes} {...listeners}>
           ... something
          </div>
        </div>
        <div className={curStyle.appDesc} {...attributes} {...listeners}>
					... something
        </div>
      </div>
    </div>
  );
};

export default SoftCard;