現在有很多優秀的拖拽佈局工具,表單設計器,layui拖拽佈局, Vue-Layout。
我們最近也實現了類似的功能,廢話不多說,先把預覽貼出來(不知道為什麼掘金現在圖片不支援gif了,還要自己上傳到圖床)。
在實現這個的功能的過程中,也走了一點彎路,我們內部1.0版本的時候,使用的是sortablejs
,由於程式碼寫的比較混亂,拖拽功能經常出現卡死的現象,以為是sortablejs
的問題,然後又換成大名鼎鼎的React Dnd
,和Redux
是同一個作者,但是Dnd並不是太符合我們的需求,拖拽的API確實很強大,但是排序、跨級拖拽等好多功能都要自己手動實現,在實現完跨級拖拽以後,老大讓我換成了sortablejs
。
拖拽工具:sortablejs ,React Dnd
我們還是先說下思路,還有我們在1.0裡給自己挖的坑,你們也要小心哈?。
如果有過樹元件開發經驗的小夥伴,應該對遞迴很熟悉了,左側的頁面結構要用到,右側的渲染也要用到,整體來說,左側的元件樹和右側的畫布區,就是兩個遞迴函式。
頁面即陣列,元件即物件
我們要生成頁面,肯定不是隻看看頁面長什麼樣子,好不好看,而是要把資料儲存起來,生成我們想要的格式,首先就要面臨的問題是,這個資料應該長什麼樣子,都有哪些欄位,分別幹什麼用。
頁面即陣列很好理解,一個頁面上可以有多個元件,而且有順序,用陣列就比較方便了,如果有子元素怎麼辦,不管vue還是react,UI框裡都會有Tree
元件,資料格式都是使用children
向下巢狀資料組,這點好理解吧?
舉個例子,iview的tree元件文件
子元素和我們有什麼關係呢,我們看表單設計器和layui拖拽佈局裡,都有容器元件,什麼意思呢,就是可以在這個元件下繼續拖拽放入元件,如果資料無限的向下延伸,那就需要children
幫忙向下無限巢狀。
巢狀的問題解決了,元件怎麼渲染呢,我們先不考慮拖拽的問題,單單一個資料物件渲染成元件,怎麼實現呢?
先大致想一下,我們如果使用Ant Design
的元件,我們得知道元件的名稱吧?得有自己props
吧?
暫定兩個欄位name
和attr
,包括上面提到的children
欄位,我們先簡單的做個demo,用ant的模板。
import React, { Component } from 'react';
import { Rate,Input,DatePicker } from 'antd';
const { MonthPicker, RangePicker, WeekPicker } = DatePicker;
const GlobalComponent = {
Rate,
Input,
MonthPicker,
RangePicker,
WeekPicker,
}
class EditPage extends Component {
render() {
// 測試資料
const Data = [
{
name: 'Input',
attr: {
size:'large',
value:'第一個'
}
},
{
name: 'Input',
attr: {
size:'default',
value:'第二個'
}
},
{
name: 'Input',
attr: {
size:'small',
value:'第三個'
}
},
{
name: 'Containers',
attr: {
style:{
border:'1px solid red'
}
},
children:[
{
name: 'Input',
attr: {
size:'small',
value:'巢狀的input'
}
},
{
name: 'Rate',
attr: {
size:'small',
value:'巢狀的input'
}
},
{
name: 'MonthPicker',
attr: {}
},
{
name: 'RangePicker',
attr: {}
},
{
name: 'WeekPicker',
attr: {}
},
]
},
];
// 遞迴函式
const loop = (arr) => (
arr.map(item => {
if(item.children){
return <div {...item.attr} >{loop(item.children)}</div>
}
const ComponentInfo = GlobalComponent[item.name]
return <ComponentInfo {...item.attr} />
})
);
return (
<>
{loop(Data)}
</>
);
}
}
export default EditPage;
複製程式碼
頁面已經渲染出來了,怎麼樣 很簡單吧? 接下來 我們一起來實現拖拽吧
拖拽的實現
資料格式我們有了,渲染也實現了,剩下的就是拖拽了,我們先了解一下了sortablejs這個外掛,官方有提供react
版的元件react-sortablejs
。
安裝依賴,在頁面中引入元件,不多說,看react-sortablejs文件。
接下來先說一下sortablejs
提供給我們什麼功能。
- 如果從容器A拖拽到容器B,兩個容器
group
引數的name
保持一致才能實現相互拖拽。 - 容器是否可移入和移出,是在
group
中配置pull
和put
屬性。 - 容器有兩個監聽事件,一個是移入的
onAdd
方法,一個是更新的onUpdate
方法 onAdd
和onUpdate
只能監聽到拖拽元素的data-id
屬性
我們怎麼藉助這些功能實現?
- 元件列表,就是左側供我們拖拽的源元件列表,不能移出,並且
data-id
需要為元件名稱,才能告知右側的容器拖拽進入的是什麼元件。 - 右側容器需要巢狀,遞迴展示,有子元素時要展示容器而不是元件。
- 右側的容器要可移入移出,方便跨容器拖拽。
- 為了把新增的元件資料放進右側對應的位置,右側容器的
data-id
修改為下標的路徑2-3-2
這樣的形式,對應根陣列的第2個元素的第3個子元素的第2個子元素。
好了,現在先把供我們拖拽的源元件列表寫出來
然後把右側的容器改造一下,如果有子元素則展示一個新的容器,並且加上add的監聽方法。
需要注意的是,跨級拖拽的時候觸發onAdd
,需要判斷一下進入的data-id
到底是下標還是元件,如果為元件直接新增,如果為下標,則對比一下新增和刪除的路徑,先操作靠下方的路徑,再操作靠上的路徑。
import React, { Component } from 'react';
import { Rate,Input,DatePicker,Tag } from 'antd';
import Sortable from 'react-sortablejs';
import uniqueId from 'lodash/uniqueId';
const { MonthPicker, RangePicker, WeekPicker } = DatePicker;
import { indexToArray, getItem, setInfo, isPath, getCloneItem, itemRemove, itemAdd } from './utils';
import find from 'find-process';
const GlobalComponent = {
Rate,
Input,
MonthPicker,
RangePicker,
WeekPicker,
}
const soundData = [
{
name: 'MonthPicker',
attr: {}
},
{
name: 'RangePicker',
attr: {}
},
{
name: 'WeekPicker',
attr: {}
},
{
name: 'Input',
attr: {
size:'large',
value:'第一個'
}
},
{
name: 'Containers',
attr: {
style:{
border:'1px solid red'
}
},
}
]
class EditPage extends Component {
constructor(props) {
super(props);
this.state = {
Data:[{
name: 'Input',
attr: {
size:'large',
value:'第一個'
}
}],
};
}
// 拖拽的新增方法
sortableAdd = evt => {
// 元件名或路徑
const nameOrIndex = evt.clone.getAttribute('data-id');
// 父節點路徑
const parentPath = evt.path[1].getAttribute('data-id');
// 拖拽元素的目標路徑
const { newIndex } = evt;
// 新路徑 為根節點時直接使用index
const newPath = parentPath ? `${parentPath}-${newIndex}` : newIndex;
// 判斷是否為路徑 路徑執行移動,非路徑為新增
if (isPath(nameOrIndex)) {
// 舊的路徑index
const oldIndex = nameOrIndex;
// 克隆要移動的元素
const dragItem = getCloneItem(oldIndex, this.state.Data)
// 比較路徑的上下位置 先執行靠下的資料 再執行考上資料
if (indexToArray(oldIndex) > indexToArray(newPath)) {
// 刪除元素 獲得新資料
let newTreeData = itemRemove(oldIndex, this.state.Data);
// 新增拖拽元素
newTreeData = itemAdd(newPath, newTreeData, dragItem)
// 更新檢視
this.setState({Data:newTreeData})
return
}
// 新增拖拽元素
let newData = itemAdd(newPath, this.state.Data, dragItem)
// 刪除元素 獲得新資料
newData = itemRemove(oldIndex, newData);
this.setState({Data:newData})
return
}
// 新增流程 建立元素 => 插入元素 => 更新檢視
const id = nameOrIndex
const newItem = _.cloneDeep(soundData.find(item => (item.name === id)))
// 為容器或者彈框時增加子元素
if ( newItem.name === 'Containers') {
const ComponentsInfo = _.cloneDeep(GlobalComponent[newItem.name])
// 判斷是否包含預設資料
newItem.children = []
}
let Data = itemAdd(newPath, this.state.Data, newItem)
this.setState({Data})
}
render() {
// 遞迴函式
const loop = (arr,index) => (
arr.map((item,i) => {
const indexs = index === '' ? String(i) : `${index}-${i}`;
if(item.children){
return <div {...item.attr}
data-id={indexs}
>
<Sortable
key={uniqueId()}
style={{
minHeight:100,
margin:10,
}}
ref={c => c && (this.sortable = c.sortable)}
options={{
...sortableOption,
// onUpdate: evt => (this.sortableUpdate(evt)),
onAdd: evt => (this.sortableAdd(evt)),
}}
>
{loop(item.children,indexs)}
</Sortable>
</div>
}
const ComponentInfo = GlobalComponent[item.name]
return <div data-id={indexs}><ComponentInfo {...item.attr} /></div>
})
)
const sortableOption = {
animation: 150,
fallbackOnBody: true,
swapThreshold: 0.65,
group: {
name: 'formItem',
pull: true,
put: true,
},
}
return (
<>
<h2>元件列表</h2>
<Sortable
options = {{
group:{
name: 'formItem',
pull: 'clone',
put: false,
},
sort: false,
}}
>
{
soundData.map(item => {
return <div data-id={item.name}><Tag>{item.name}</Tag></div>
})
}
</Sortable>
<h2>容器</h2>
<Sortable
ref={c => c && (this.sortable = c.sortable)}
options={{
...sortableOption,
// onUpdate: evt => (this.sortableUpdate(evt)),
onAdd: evt => (this.sortableAdd(evt)),
}}
key={uniqueId()}
>
{loop(this.state.Data,'')}
</Sortable>
</>
);
}
}
export default EditPage;
複製程式碼
現在跨級操作和新增已經完成了,接下來我們補充一下同級交換位置的功能,我們用了immutability-helper
這個工具函式,具體的自己看文件吧,只是用到了陣列換位。
import update from 'immutability-helper'
// 拖拽的排序方法
sortableUpdate = evt => {
// 交換陣列
const { newIndex, oldIndex } = evt;
// 父節點路徑
const parentPath = evt.path[1].getAttribute('data-id');
// 父元素 根節點時直接呼叫data
let parent = parentPath ? getItem(parentPath, this.state.Data) : this.state.Data;
// 當前拖拽元素
const dragItem = parent[oldIndex];
// 更新後的父節點
parent = update(parent, {
$splice: [[oldIndex, 1], [newIndex, 0, dragItem]],
});
// 最新的資料 根節點時直接呼叫data
const Data = parentPath ? setInfo(parentPath, this.state.Data, parent) : parent
// 呼叫父元件更新方法
this.setState({Data})
}
複製程式碼
現在跨級和同級的排序的功能都已經完成了,我們看看預覽圖吧。
在onUpdate
和onAdd
的函式中,自己封裝了一些根據下標運算元組的方法,也是按照函式式的方式,每個函式返回新的結果,寫的不是特別好,多多見諒哈,剩下的刪除、選中啦,根據自己的需求增加功能就可以了,我把原始碼放在了github上,有需要的拿去吧,碼字碼到手痠,吃飯去了?。