一、背景
根据需求,需要对多层级的Table结构做拖拽排序处理,支持跨层级拖拽排序;
要求界面功能简洁,一目了然,无明显卡顿。
根据需求查看市面上常见的拖拽方案
-
- 使用dnd-kit完成,例子主要是同层级排序
样式上较为满足,缺点是需求是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,
);
},
});