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}]
}
总体设计
拖拽的本质是 改变 数据结构中数据项的位置后,再重新渲染视图
-
我们需要 维护一个 TSoftGroup 类型的数据对象,后续统称为 softGroups(首次加载时从后端获取)
-
前端视图层的渲染只基于 前端存储的 softGroups,但是在进入页面,或者刷新页面时,都会向后端发送请求来更新 softGroups
-
将获取到的 softGroups 存为前端状态,此时,相当于前后端各自保存了一份 softGroups ,且他们记录的信息完全一致
-
执行一次拖拽操作,成功交换两个元素位置。此时前端的 softGroups 已经更新,但后端的 softGroups 还没有更新
-
所以我们需要在每次拖拽后,将本次更改的信息传给后端(拖拽元素的 id 、weight),后端根据这些信息去更新 服务器中的 softGroups,以便和前后端保持一致
可优化之处
-
可以先判断元素是否真正地和其他元素交换了位置(将元素选中拖拽后,立马松开鼠标或者将其放回原处,所以实际上此时元素未移动),如果元素实际上未移动,便不用向后端发送请求更新 softGroups
遇见的问题
由于 softGroups 是引用类型,且拖拽过程中,dnd-kit 抛出来的 hook 的调用顺序分别为:
onDragStart - onDragOver - onDragEnd - onDragCancel
下列现象详细解释起来缘由过多,所以只需知道最终有这么些问题即可
- onDragOver 、 onDragEnd 中暴露出来的 active、over 等参数都是引用,会互相影响且边界情况很多。example:当目标分组是空分组时,over.id的值会是分组的id
- 拖拽元素的过程中(onDragOver),我们为了视图上的过渡动画效果,此时就已经更改了 softGroups
- 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;