前段時(shí)間,項(xiàng)目里有一個(gè)新的需求是關(guān)于三級拖拽的培己,我本身也在參與其它項(xiàng)目的開發(fā)索抓,并沒有時(shí)間做技術(shù)調(diào)研钧忽,慶幸同事有人做過相似需求的開發(fā),經(jīng)過同事的技術(shù)支持逼肯,在需求規(guī)定的時(shí)間內(nèi)完成了開發(fā)耸黑,在這里我要感謝我的同事的幫助,感謝團(tuán)隊(duì)里的每一個(gè)人篮幢,筆芯大刊。
再次回顧這次需求,時(shí)間上有明確的 deadline 三椿,功能和業(yè)務(wù)的復(fù)雜度超乎預(yù)期想象缺菌,而且沒有足夠的開發(fā)時(shí)間葫辐,在參加需求開發(fā)之前有其它的需求開發(fā)并沒有做技術(shù)調(diào)研,總結(jié)有以下幾個(gè)重要的點(diǎn):
大的功能開發(fā)前如果有技術(shù)難點(diǎn)一定要做調(diào)研男翰。
開發(fā)前必須要明確需求的點(diǎn)另患,包括 ui 交互。
預(yù)估時(shí)間一定要預(yù)留足夠的 buffer 蛾绎。
現(xiàn)在我們來說說怎樣去實(shí)現(xiàn)一個(gè)三級拖拽昆箕。
首先,完成這次功能我們使用了一個(gè) react 庫租冠。
- 該庫是一個(gè)快速鹏倘、輕量、可排序的庫顽爹。
- 該庫使用 css 轉(zhuǎn)換來制作動(dòng)畫纤泵。
- 該庫是在基于 react-dnd 基礎(chǔ)上開發(fā)的 react 拖動(dòng)效果的組件。
- 該庫現(xiàn)在有 1.4k 星镜粤,使用效果還不錯(cuò)捏题。
其次,讓我們看看怎么使用 react-smooth-dnd肉渴。
import { Container, Draggable } from 'react-smooth-dnd';
<Container onDrop={onDrop}>
{data.map((item) => (
<Draggable>
<div className={styles.item}>
{item.label}
</div>
</Draggable>
))}
</Container>
Container 標(biāo)簽: 拖動(dòng)的容器公荧,即在 Container 內(nèi)可以拖動(dòng),里面可以包容若干個(gè) Draggable 同规。
Draggable 標(biāo)簽:拖動(dòng)的元素循狰,把要拖動(dòng)的內(nèi)容放在 Draggable 里就可以實(shí)現(xiàn)拖拽。
注意:Container 下面必須直接包含 Draggable券勺,否則會(huì)報(bào)錯(cuò)绪钥。
最后,讓我們了解一下 react-smooth-dnd 都有什么 api关炼。
一. Container api
1. groupName
拖動(dòng)容器的名稱程腹,當(dāng)有多個(gè) Container 時(shí),groupName 名稱相同時(shí)可以實(shí)現(xiàn)相互拖動(dòng)盗扒。
2. orientation
容器的方向跪楞。可選值:
- horizontal (水平)
- vertical (默認(rèn)侣灶,垂直)
3. behaviour
規(guī)定了拖動(dòng)元素的狀態(tài)甸祭,可選值有 4 個(gè):
- move(默認(rèn),移動(dòng))
- copy(復(fù)制)
- drop-zone(跌落)
- contain(包含)
4. lockAxis
限制當(dāng)前拖動(dòng)的方向褥影〕鼗В可選值有:x, y,表示 x, y 軸拖動(dòng)。
5. dragClass
拖動(dòng)元素被拖動(dòng)時(shí)的樣式校焦。
6. dropClass
拖動(dòng)元素被釋放時(shí)的樣式赊抖。
7. dropPlaceholder
拖動(dòng)元素拖走時(shí)或進(jìn)入其他位置時(shí),用于占位當(dāng)前元素的配置寨典。
可配置值:
- className 占位元素樣式
- animationDuration 動(dòng)畫延時(shí)
- showOnTop
8. dragBeginDelay
延時(shí)拖動(dòng)氛雪,時(shí)間為毫秒。用在防誤操作的情況耸成,或者在拖動(dòng)的元素上有其它的事件的情況报亩。
9. onDragStart
拖動(dòng)開始會(huì)觸發(fā)此事件
10. onDragEnd
拖動(dòng)結(jié)束會(huì)觸發(fā)此事件
11. onDrop
拖動(dòng)釋放會(huì)觸發(fā)此事件
12. getChildPayload
記錄當(dāng)前拖動(dòng)元素的信息,使用該函數(shù)返回一個(gè) payload 的值井氢。當(dāng) onDrop 觸發(fā)時(shí)弦追,會(huì)自動(dòng)帶入該函數(shù)返回的信息,用于做數(shù)據(jù)的處理花竞。
13. onDragEnter
拖動(dòng)進(jìn)入會(huì)觸發(fā)此事件
14. onDragLeave
拖動(dòng)離開會(huì)觸發(fā)此事件
15. getGhostParent
當(dāng)多層拖動(dòng)且容器名稱相同時(shí)劲件,下層元素向上拖動(dòng)會(huì)出現(xiàn)拖動(dòng)元素不可見,此時(shí)可以設(shè)置此函數(shù)约急。
二. Draggable api
1. render
<Draggable render={() => {
return (
<li>
...
</li>
)
}}/>
render 返回一個(gè) dom 元素零远。
當(dāng) render 存在時(shí)會(huì)忽略 Draggable 的 children。
做好準(zhǔn)備工作之后厌蔽,現(xiàn)在我們來簡單實(shí)現(xiàn)一下一層的排序功能:
import React, { useState } from 'react';
import { connect } from 'dva';
import { Container, Draggable } from 'react-smooth-dnd';
import styles from './IndexPage.css';
const list = [
{ label: '第一個(gè)數(shù)據(jù)', fieldName: 'data-a1', children: [] },
{ label: '第二個(gè)數(shù)據(jù)', fieldName: 'data-a2', children: [] },
{ label: '第三個(gè)數(shù)據(jù)', fieldName: 'data-a3', children: [] },
];
function IndexPage() {
const [data, setData] = useState(list);
const onDrag = (arr = [], dragResult) => {
const { removedIndex, addedIndex, payload } = dragResult;
if (removedIndex === null && addedIndex === null) {
return arr;
}
const result = [...arr];
let itemToAdd = payload;
if (removedIndex !== null) {
itemToAdd = result.splice(removedIndex, 1)[0];
}
if (addedIndex !== null) {
result.splice(addedIndex, 0, itemToAdd);
}
return result;
};
const onDrop = (dropResult) => {
const { removedIndex, addedIndex } = dropResult;
if (removedIndex !== null || addedIndex !== null) {
const list = onDrag(data, dropResult);
setData(list);
}
}
return (
<div className={styles.container}>
<Container onDrop={onDrop}>
{data.map((item) => (
<Draggable>
<div className={styles.item}>
{item.label}
</div>
</Draggable>
))}
</Container>
</div>
);
}
export default connect()(IndexPage);
可以發(fā)現(xiàn)遍烦,實(shí)現(xiàn)一層的功能是很簡單的,首先是準(zhǔn)備好數(shù)據(jù)躺枕,寫好 dom 結(jié)構(gòu),最后寫好 onDrop 函數(shù)供填,一層排序的功能就做好了拐云。是不是 so easy ?
那么現(xiàn)在我們嘗試實(shí)現(xiàn)三層的功能。三層如何實(shí)現(xiàn)呢近她?我們可以假設(shè)每一個(gè)拖動(dòng)的元素同時(shí)又是一個(gè)容器叉瘩,即每一個(gè) Draggable 下面又有一個(gè) Container ,不就實(shí)現(xiàn)了嵌套嗎?現(xiàn)在我們實(shí)現(xiàn)一下:
第一步:準(zhǔn)備好數(shù)據(jù)粘捎。
const list = [
{ label: '第一個(gè)數(shù)據(jù)', fieldName: 'data-a1', children: [
{ label: '第二層', fieldName: 'data-b1', children: [
{ label: '第三層', fieldName: 'data-c1', children: [] },
{ label: '第三層2', fieldName: 'data-c2', children: [] },
] },
{ label: '第二層2', fieldName: 'data-b2', children: [] },
] },
{ label: '第二個(gè)數(shù)據(jù)', fieldName: 'data-a2', children: [] },
{ label: '第三個(gè)數(shù)據(jù)', fieldName: 'data-a3', children: [
{ label: '第二層3', fieldName: 'data-b3', children: [] },
] },
];
第二步:寫好 dom 結(jié)構(gòu)薇缅。
// 利用遞歸實(shí)現(xiàn)列表的渲染
const onDomRender = (renderData, parent, depth) => {
if (depth === 4) return;
return (
<Container
groupName="col"
onDrop={(value) => onDrop(value, parent, depth)}
getChildPayload={(index) => renderData[index]}
>
{
renderData.map((item) => {
return (
<Draggable key={item.fieldName}>
<div className={styles.item} style={depth === 1 ? {marginBottom: '20px'} : {}}>
{item.label}
<div className={styles.box}>
{onDomRender(item.children, item, depth + 1)}
</div>
</div>
</Draggable>
)
})
}
</Container>
)
};
...這里調(diào)用 onDomRender 渲染界面
<div className={styles.container}>
{onDomRender(data, null, 1)}
</div>
第三步:完成 onDrop 函數(shù)。
// 處理拖拽的移除攒磨、添加
const onDrag = (arr = [], dragResult) => {
const { removedIndex, addedIndex, payload } = dragResult;
if (removedIndex === null && addedIndex === null) {
return arr;
}
const result = [...arr];
let itemToAdd = payload;
if (removedIndex !== null) {
itemToAdd = result.splice(removedIndex, 1)[0];
}
if (addedIndex !== null) {
result.splice(addedIndex, 0, itemToAdd);
}
return result;
};
// 獲取數(shù)據(jù)的索引
const onGetIndex = (item, tempData, indexArr, lastIndex) => {
tempData.length > 0 && tempData.forEach((ele, index) => {
if (item.fieldName === ele.fieldName) {
if (lastIndex !== undefined) {
indexArr.push(lastIndex);
indexArr.push(index);
} else {
indexArr.push(index);
}
} else if (ele.children.length && ele.children.length > 0) {
onGetIndex(item, ele.children, indexArr, index);
}
})
return indexArr;
}
// 處理跨層級的拖拽
const onTreate = (current) => {
const {
removedIndex,
addedIndex,
removedDepth,
addedDepth,
removedParent,
addedParent,
} = current;
if (removedIndex !== null && addedIndex !== null && removedIndex !== undefined && addedIndex !== undefined){
let result = cloneDeep(data);
// 添加
if (addedDepth === 1) {
result = onDrag(result, {...current, removedIndex: null});
} else if (addedDepth === 2) {
const tempData = onDrag(addedParent.children, {...current, removedIndex: null});
const index = onGetIndex(addedParent, result, []);
result[index[0]].children = tempData;
} else if (addedDepth === 3) {
const tempData = onDrag(addedParent.children, {...current, removedIndex: null});
const index = onGetIndex(addedParent, result, []);
result[index[0]].children[index[1]].children = tempData;
}
// 移除
if (removedDepth === 1) {
result = onDrag(result, {...current, addedIndex: null});
} else if (removedDepth === 2) {
const index = onGetIndex(removedParent, result, []);
const parent = result[index[0]];
const tempData = onDrag(parent.children, {...current, addedIndex: null});
result[index[0]].children = tempData;
} else if (removedDepth === 3) {
const index = onGetIndex(removedParent, result, []);
const parent = result[index[0]].children[index[1]];
const tempData = onDrag(parent.children, {...current, addedIndex: null});
result[index[0]].children[index[1]].children = tempData;
}
setData(result);
setTemp({});
}
}
// 拖拽釋放
const onDrop = (dropResult, parent, depth) => {
const { removedIndex, addedIndex, payload } = dropResult;
if (removedIndex !== null || addedIndex !== null) {
// 同層拖拽
if (removedIndex !== null && addedIndex !== null) {
let result = cloneDeep(data);
if (depth === 1) {
result = onDrag(data, dropResult);
} else if (depth === 2) {
const tempData = onDrag(parent.children, dropResult);
const index = onGetIndex(parent, result, []);
result[index[0]].children = tempData;
} else if (depth === 3) {
const tempData = onDrag(parent.children, dropResult);
const index = onGetIndex(parent, result, []);
result[index[0]].children[index[1]].children = tempData;
}
setData(result);
// 跨層拖拽泳桦,第一次執(zhí)行
} else if (temp.removedIndex === undefined && temp.addedIndex === undefined) {
let flag = addedIndex !== null ? {addedDepth: depth, addedParent: parent} : {removedDepth: depth, removedParent: parent};
setTemp({...dropResult, ...flag});
// 跨層拖拽,非第一次執(zhí)行(第一次執(zhí)行了添加)
} else if (temp.addedIndex !== null && temp.removedIndex === null && temp.payload.fieldName === payload.fieldName) {
const current = { ...temp, removedIndex, removedDepth: depth, removedParent: parent };
// 已有添加娩缰、移除操作灸撰,開始執(zhí)行跨層級拖拽
onTreate(current);
// 跨層拖拽,非第一次執(zhí)行(第一次執(zhí)行了移除)
} else if (temp.removedIndex !== null && temp.addedIndex === null && temp.payload.fieldName === payload.fieldName) {
const current ={ ...temp, addedIndex, addedDepth: depth, addedParent: parent };
// 已有移除、添加操作浮毯,開始執(zhí)行跨層級拖拽
onTreate(current);
}
}
}
onDrop 函數(shù)處理相同層級的拖拽時(shí)完疫,根據(jù) depth 來決定處理幾層的數(shù)據(jù),然后更新 state 來重新渲染頁面债蓝;處理跨層級的拖拽時(shí)調(diào)用了 onTreate 函數(shù)壳鹤,這里為什么要加 temp 來存儲(chǔ)上次的操作呢?因?yàn)?react-smooth-dnd 在拖動(dòng)元素時(shí)無法保證先返回添加事件或者移除事件饰迹,所以我們將上次的操作(添加芳誓、移除)存儲(chǔ)在 temp 里,等到下個(gè)操作(移除蹦锋、添加)時(shí)再來處理拖動(dòng)事件兆沙,并把 temp 置 {}。
onTreate 函數(shù)負(fù)責(zé)處理跨層級的拖拽莉掂。先執(zhí)行添加操作葛圃,后執(zhí)行移除操作。在這里調(diào)用了 onDrag 函數(shù)來執(zhí)行添加憎妙、移除的操作库正,onGetIndex 函數(shù)來確定處理數(shù)據(jù)的索引。
注意:在執(zhí)行移除操作時(shí)要保證處理的是最新的數(shù)據(jù)厘唾。
最后褥符,我們看一下完整的代碼。
import React, { useState, useEffect } from 'react';
import { connect } from 'dva';
import { cloneDeep } from 'lodash';
import { Container, Draggable } from 'react-smooth-dnd';
import styles from './IndexPage.css';
const list = [
{ label: '第一個(gè)數(shù)據(jù)', fieldName: 'data-a1', children: [
{ label: '第二層', fieldName: 'data-b1', children: [
{ label: '第三層', fieldName: 'data-c1', children: [] },
{ label: '第三層2', fieldName: 'data-c2', children: [] },
] },
{ label: '第二層2', fieldName: 'data-b2', children: [] },
] },
{ label: '第二個(gè)數(shù)據(jù)', fieldName: 'data-a2', children: [] },
{ label: '第三個(gè)數(shù)據(jù)', fieldName: 'data-a3', children: [
{ label: '第二層3', fieldName: 'data-b3', children: [] },
] },
];
function IndexPage() {
const [data, setData] = useState([]);
const [temp, setTemp] = useState({});
useEffect(() => {
setData(list);
}, []);
const onDrag = (arr = [], dragResult) => {
const { removedIndex, addedIndex, payload } = dragResult;
if (removedIndex === null && addedIndex === null) {
return arr;
}
const result = [...arr];
let itemToAdd = payload;
if (removedIndex !== null) {
itemToAdd = result.splice(removedIndex, 1)[0];
}
if (addedIndex !== null) {
result.splice(addedIndex, 0, itemToAdd);
}
return result;
};
const onGetIndex = (item, tempData, indexArr, lastIndex) => {
tempData.length > 0 && tempData.forEach((ele, index) => {
if (item.fieldName === ele.fieldName) {
if (lastIndex !== undefined) {
indexArr.push(lastIndex);
indexArr.push(index);
} else {
indexArr.push(index);
}
} else if (ele.children.length && ele.children.length > 0) {
onGetIndex(item, ele.children, indexArr, index);
}
})
return indexArr;
}
const onTreate = (current) => {
const {
removedIndex,
addedIndex,
removedDepth,
addedDepth,
removedParent,
addedParent,
} = current;
if (removedIndex !== null && addedIndex !== null && removedIndex !== undefined && addedIndex !== undefined){
let result = cloneDeep(data);
if (addedDepth === 1) {
result = onDrag(result, {...current, removedIndex: null});
} else if (addedDepth === 2) {
const tempData = onDrag(addedParent.children, {...current, removedIndex: null});
const index = onGetIndex(addedParent, result, []);
result[index[0]].children = tempData;
} else if (addedDepth === 3) {
const tempData = onDrag(addedParent.children, {...current, removedIndex: null});
const index = onGetIndex(addedParent, result, []);
result[index[0]].children[index[1]].children = tempData;
}
if (removedDepth === 1) {
result = onDrag(result, {...current, addedIndex: null});
} else if (removedDepth === 2) {
const index = onGetIndex(removedParent, result, []);
const parent = result[index[0]];
const tempData = onDrag(parent.children, {...current, addedIndex: null});
result[index[0]].children = tempData;
} else if (removedDepth === 3) {
const index = onGetIndex(removedParent, result, []);
const parent = result[index[0]].children[index[1]];
const tempData = onDrag(parent.children, {...current, addedIndex: null});
result[index[0]].children[index[1]].children = tempData;
}
setData(result);
setTemp({});
}
}
const onDrop = (dropResult, parent, depth) => {
const { removedIndex, addedIndex, payload } = dropResult;
if (removedIndex !== null || addedIndex !== null) {
if (removedIndex !== null && addedIndex !== null) {
let result = cloneDeep(data);
if (depth === 1) {
result = onDrag(data, dropResult);
} else if (depth === 2) {
const tempData = onDrag(parent.children, dropResult);
const index = onGetIndex(parent, result, []);
result[index[0]].children = tempData;
} else if (depth === 3) {
const tempData = onDrag(parent.children, dropResult);
const index = onGetIndex(parent, result, []);
result[index[0]].children[index[1]].children = tempData;
}
setData(result);
} else if (temp.removedIndex === undefined && temp.addedIndex === undefined) {
let flag = addedIndex !== null ? {addedDepth: depth, addedParent: parent} : {removedDepth: depth, removedParent: parent};
setTemp({...dropResult, ...flag});
} else if (temp.addedIndex !== null && temp.removedIndex === null && temp.payload.fieldName === payload.fieldName) {
const current = { ...temp, removedIndex, removedDepth: depth, removedParent: parent };
onTreate(current);
} else if (temp.removedIndex !== null && temp.addedIndex === null && temp.payload.fieldName === payload.fieldName) {
const current ={ ...temp, addedIndex, addedDepth: depth, addedParent: parent };
onTreate(current);
}
}
}
// 利用遞歸實(shí)現(xiàn)列表的渲染
const onDomRender = (renderData, parent, depth) => {
if (depth === 4) return;
return (
<Container
groupName="col"
onDrop={(value) => onDrop(value, parent, depth)}
getChildPayload={(index) => renderData[index]}
>
{
renderData.map((item) => {
return (
<Draggable key={item.fieldName}>
<div className={styles.item} style={depth === 1 ? {marginBottom: '20px'} : {}}>
{item.label}
<div className={styles.box}>
{onDomRender(item.children, item, depth + 1)}
</div>
</div>
</Draggable>
)
})
}
</Container>
)
};
return (
<div className={styles.container}>
{onDomRender(data, null, 1)}
</div>
);
}
export default connect()(IndexPage);
現(xiàn)在我們實(shí)現(xiàn)了三層拖拽抚垃,在完成的時(shí)候發(fā)現(xiàn) getChildPayload 必須要明確給出喷楣,否則在拖拽完成后 onDrop 事件無法得到 payload 的值。
那還有其它的問題嗎鹤树?
在拖拽的過程當(dāng)中铣焊,我們也發(fā)現(xiàn)了子元素向上拖拽時(shí)會(huì)隱藏,這時(shí)我們想到了 getGhostParent 屬性罕伯,我們再看一下效果曲伊,這回子元素向上拖拽時(shí)不會(huì)再出現(xiàn)隱藏的情況了。
<Container
groupName="col"
onDrop={(value) => onDrop(value, parent, depth)}
getChildPayload={(index) => renderData[index]}
getGhostParent={() => document.body}
>
...
</Container>