拖拽是常见的需求,在 react 里我们会用 react-dnd 来做。
不管是简单的拖拽,比如拖拽排序,或者复杂的拖拽,比如低代码编辑器,react-dnd 都可以搞定。
这篇文章我们就来用 react-dnd 来实现一下这些功能吧。
我们这篇文章会实现 3 个案例:
入门案例:
拖拽排序:
低代码编辑器:
我们先入个门:
ini
npx create-react-app --template=typescript react-dnd-test
新建个 react 项目
安装 react-dnd 相关的包:
npm install react-dnd react-dnd-html5-backend
然后改一下 App.tsx
javascript
import './App.css';
function Box() {
return <div className='box'></div>
}
function Container() {
return <div className="container"></div>
}
function App() {
return <div>
<Container></Container>
<Box></Box>
</div>
}
export default App;
css 部分如下:
css
.box {
width: 50px;
height: 50px;
background: blue;
margin: 10px;
}
.container {
width: 300px;
height: 300px;
border: 1px solid #000;
}
把它跑起来:
arduino
npm run start
是这样的:
现在我们想把 box 拖拽到 container 里,用 react-dnd 怎么做呢?
dnd 是 drag and drop 的意思,api 也分有两个 useDrag 和 useDrop。
box 部分用 useDrag 让元素可以拖拽:
javascript
function Box() {
const ref = useRef(null);
const [, drag]= useDrag({
type: 'box',
item: {
color: 'blue'
}
});
drag(ref);
return <div ref={ref} className='box'></div>
}
用 useRef 保存 dom 引用,然后用 useDrag 返回的第二个参数处理它。
至于 type 和 item,后面再讲。
然后是 Container:
javascript
function Container() {
const ref = useRef(null);
const [,drop] = useDrop(() => {
return {
accept: 'box',
drop(item) {
console.log(item);
}
}
});
drop(ref);
return <div ref={ref} className="container"></div>
}
用 useDrop 让它可以接受拖拽过来的元素。
接收什么元素呢?
就是我们 useDrag 的时候声明的 type 的元素。
在 drop 的时候会触发 drop 回调函数,第一个参数是 item,就是 drag 的元素声明的那个。
只是这样还不行,还要在根组件加上 Context:
javascript
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(<DndProvider backend={HTML5Backend}><App></App></DndProvider>);
之前是直接渲染 App,现在要在外面加上 DndProvider。
这个就是设置 dnd 的 context的,用于在不同组件之间共享数据。
然后我们试试看:
确实,现在元素能 drag 了,并且拖到目标元素也能触发 drop 事件,传入 item 数据。
那如果 type 不一样呢?
那就触发不了 drop 了。
然后我们给 Box 组件添加一个 color 的 props,用来设置背景颜色:
并且给 item 的数据加上类型。
javascript
interface ItemType {
color: string;
}
interface BoxProps {
color: string
}
function Box(props: BoxProps) {
const ref = useRef(null);
const [, drag]= useDrag({
type: 'box',
item: {
color: props.color
}
});
drag(ref);
return <div ref={ref} className='box' style={
{ background: props.color || 'blue'}
}></div>
}
添加几个 Box 组件试一下:
没啥问题。
然后我们改下 Container 组件,增加一个 boxes 数组的 state,在 drop 的时候把 item 加到数组里,并触发渲染:
javascript
function Container() {
const [boxes, setBoxes] = useState<ItemType[]>([]);
const ref = useRef(null);
const [,drop] = useDrop(() => {
return {
accept: 'box',
drop(item: ItemType) {
setBoxes((boxes) => [...boxes, item])
}
}
});
drop(ref);
return <div ref={ref} className="container">
{
boxes.map(item => {
return <Box color={item.color}></Box>
})
}
</div>
}
这里 setBoxes 用了函数的形式,这样能拿到最新的 boxes 数组,不然会形成闭包,始终引用最初的空数组。
测试下:
这样,拖拽到容器里的功能就实现了。
我们再加上一些拖拽过程中的效果:
useDrag 可以传一个 collect 的回调函数,它的参数是 monitor,可以拿到拖拽过程中的状态。
collect 的返回值会作为 useDrag 的返回的第一个值。
我们判断下,如果是在 dragging 就设置一个 dragging 的 className。
javascript
function Box(props: BoxProps) {
const ref = useRef(null);
const [{dragging}, drag]= useDrag({
type: 'box',
item: {
color: props.color
},
collect(monitor) {
return {
dragging: monitor.isDragging()
}
}
});
drag(ref);
return <div ref={ref} className={ dragging ? 'box dragging' : 'box'} style={
{ background: props.color || 'blue'}
}></div>
}
然后添加 dragging 的样式:
css
.dragging {
border: 5px dashed #000;
box-sizing: border-box;
}
测试下:
确实,这样就给拖拽中的元素加上了对应的样式。
但如果我们想把这个预览的样式也给改了呢?
这时候就要新建个组件了:
javascript
const DragLayer = () => {
const { isDragging, item, currentOffset} = useDragLayer((monitor) => ({
item: monitor.getItem(),
isDragging: monitor.isDragging(),
currentOffset: monitor.getSourceClientOffset(),
}));
if (!isDragging) {
return null;
}
return (
<div className='drag-layer' style={{
left: currentOffset?.x,
top: currentOffset?.y
}}>{item.color} 拖拖拖</div>
);
}
useDragLayer 的参数是函数,能拿到 monitor,从中取出很多东西,比如 item、isDragging,还是有 clientOffset,也就是拖拽过程中的坐标。
其中 drag-layer 的样式如下:
css
.drag-layer {
position: fixed;
}
引入下这个组件:
现在的效果是这样的:
确实加上了自定义的预览样式,但是原来的还保留着。
可以这样去掉:
useDrag 的第三个参数就是处理预览元素的,我们用 getEmptyImage 替换它,这样就看不到了。
但是依然要捕获 dragging 状态。
也就是这样:
javascript
dragPreview(getEmptyImage(), { captureDraggingState: true })
这样就只有我们自定义的预览样式了:
但其实这种逻辑只要执行一次就行了,我们优化一下:
javascript
useEffect(() => {
drag(ref);
dragPreview(getEmptyImage(), { captureDraggingState: true });
}, [])
drop 的逻辑也同样:
javascript
useEffect(()=> {
drop(ref);
}, []);
这样,我们就学会了 react-dnd 的基本使用。
总结下:
- 使用 useDrag 处理拖拽的元素,使用 useDrop 处理 drop 的元素,使用 useDragLayer 处理自定义预览元素
- 在根组件使用 DndProvider 设置 context 来传递数据
- useDrag 可以传入 type、item、collect 等。type 标识类型,同类型才可以 drop。item 是传递的数据。collect 接收 monitor,可以取拖拽的状态比如 isDragging 返回。
- useDrag 返回三个值,第一个值是 collect 函数返回值,第二个是处理 drag 的元素的函数,第三个值是处理预览元素的函数
- useDrop 可以传入 accept、drop 等。accept 是可以 drop 的类型。drop 回调函数可以拿到 item,也就是 drag 元素的数据
- useDragLayer 的回调函数会传入 monitor,可以拿到拖拽的实时坐标,用来设置自定义预览效果
全部代码如下:
javascript
import { useDrag, useDragLayer, useDrop } from 'react-dnd';
import './App.css';
import { useEffect, useRef, useState } from 'react';
import { getEmptyImage } from 'react-dnd-html5-backend'
interface ItemType {
color: string;
}
interface BoxProps {
color: string
}
function Box(props: BoxProps) {
const ref = useRef(null);
const [{dragging}, drag, dragPreview]= useDrag({
type: 'box',
item: {
color: props.color
},
collect(monitor) {
return {
dragging: monitor.isDragging()
}
}
});
useEffect(() => {
drag(ref);
dragPreview(getEmptyImage(), { captureDraggingState: true });
}, [])
return <div ref={ref} className={ dragging ? 'box dragging' : 'box'} style={
{ background: props.color || 'blue'}
}></div>
}
function Container() {
const [boxes, setBoxes] = useState<ItemType[]>([]);
const ref = useRef(null);
const [,drop] = useDrop(() => {
return {
accept: 'box',
drop(item: ItemType) {
setBoxes((boxes) => [...boxes, item])
}
}
});
useEffect(()=> {
drop(ref);
}, []);
return <div ref={ref} className="container">
{
boxes.map(item => {
return <Box color={item.color}></Box>
})
}
</div>
}
const DragLayer = () => {
const { isDragging, item, currentOffset} = useDragLayer((monitor) => ({
item: monitor.getItem(),
isDragging: monitor.isDragging(),
currentOffset: monitor.getSourceClientOffset(),
}));
if (!isDragging) {
return null;
}
return (
<div className='drag-layer' style={{
left: currentOffset?.x,
top: currentOffset?.y
}}>{item.color}拖拖拖</div>
);
}
function App() {
return <div>
<Container></Container>
<Box color="blue"></Box>
<Box color="red"></Box>
<Box color="green"></Box>
<DragLayer></DragLayer>
</div>
}
export default App;
css:
css
.box {
width: 50px;
height: 50px;
background: blue;
margin: 10px;
}
.dragging {
border: 5px dashed #000;
box-sizing: border-box;
}
.drag-layer {
position: fixed;
}
.container {
width: 300px;
height: 300px;
border: 1px solid #000;
}
入了门之后,我们再来做个进阶案例:拖拽排序
我们写个 App2.tsx
javascript
import { useState } from "react";
import './App2.css';
interface CardItem {
id: number;
content: string;
}
interface CardProps {
data: CardItem
}
function Card(props: CardProps) {
const { data } = props;
return <div className="card">{data.content}</div>
}
function App() {
const [cardList, setCardList] = useState<CardItem[]>([
{
id:0,
content: '000',
},
{
id:1,
content: '111',
},
{
id:2,
content: '222',
},
{
id:3,
content: '333',
},
{
id:4,
content: '444',
}
]);
return <div className="card-list">
{
cardList.map((item: CardItem) => (
<Card data={item} key={'card_' + item.id} />
))
}
</div>
}
export default App;
还有 App2.css:
css
.card {
width: 200px;
line-height: 60px;
padding: 0 20px;
border: 1px solid #000;
margin: 10px;
cursor: move;
}
就是根据 cardList 的数据渲染一个列表。
把它渲染出来是这样的:
拖拽排序,显然 drag 和 drop 的都是 Card。
我们给它加上 useDrag 和 useDrop:
javascript
function Card(props: CardProps) {
const { data } = props;
const ref = useRef(null);
const [, drag] = useDrag({
type: 'card',
item: props.data
});
const [, drop] = useDrop({
accept: 'card',
drop(item) {
console.log(item);
}
});
useEffect(() => {
drag(ref);
drop(ref);
}, []);
return <div ref={ref} className="card">{data.content}</div>
}
接下来做的很显然就是交换位置了。
我们实现一个交换位置的方法,传入 Card 组件,并且把当前的 index 也传入:
javascript
const swapIndex = useCallback((index1: number, index2: number) => {
const tmp = cardList[index1];
cardList[index1] = cardList[index2];
cardList[index2] = tmp;
setCardList([...cardList]);
}, [])
这里 setState 时需要创建一个新的数组,才能触发渲染。
然后在 Card 组件里调用下:
增加 index 和 swapIndex 两个参数,声明 drag 传递的 item 数据的类型
在 drop 的时候互换 item.index 和当前 drop 的 index 的 Card
javascript
interface CardProps {
data: CardItem;
index: number;
swapIndex: Function;
}
interface DragData {
id: number;
index: number;
}
function Card(props: CardProps) {
const { data, swapIndex, index } = props;
const ref = useRef(null);
const [, drag] = useDrag({
type: 'card',
item: {
id: data.id,
index: index
}
});
const [, drop] = useDrop({
accept: 'card',
drop(item: DragData) {
swapIndex(index, item.index)
}
});
useEffect(() => {
drag(ref);
drop(ref);
}, []);
return <div ref={ref} className="card">{data.content}</div>
}
这样就实现了拖拽排序。
不过因为背景是透明的,看着不是很明显。
我们设置个背景色:
清晰多了。
但是现在是 drop 的时候才改变位置,如果希望在 hover 的时候就改变位置呢?
useDrop 有 hover 时的回调函数,我们把 drop 改成 hover就好了:
但现在你会发现它一直在换:
那是因为交换位置后,没有修改 item.index
在 hover 时就改变顺序,体验好多了。
然后我们再处理下拖拽时的样式。
样式如下:
css
.dragging {
border-style: dashed;
background: #fff;
}
效果是这样的:
这样,拖拽排序就完成了。
我们对 react-dnd 的掌握又加深了一分。
这个案例的全部代码如下:
javascript
import { useCallback, useEffect, useRef, useState } from "react";
import './App2.css';
import { useDrag, useDrop } from "react-dnd";
interface CardItem {
id: number;
content: string;
}
interface CardProps {
data: CardItem;
index: number;
swapIndex: Function;
}
interface DragData {
id: number;
index: number;
}
function Card(props: CardProps) {
const { data, swapIndex, index } = props;
const ref = useRef(null);
const [{ dragging }, drag] = useDrag({
type: 'card',
item: {
id: data.id,
index: index
},
collect(monitor) {
return {
dragging: monitor.isDragging()
}
}
});
const [, drop] = useDrop({
accept: 'card',
hover(item: DragData) {
swapIndex(index, item.index);
item.index = index;
}
// drop(item: DragData) {
// swapIndex(index, item.index)
// }
});
useEffect(() => {
drag(ref);
drop(ref);
}, []);
return <div ref={ref} className={ dragging ? 'card dragging' : 'card'}>{data.content}</div>
}
function App() {
const [cardList, setCardList] = useState<CardItem[]>([
{
id:0,
content: '000',
},
{
id:1,
content: '111',
},
{
id:2,
content: '222',
},
{
id:3,
content: '333',
},
{
id:4,
content: '444',
}
]);
const swapIndex = useCallback((index1: number, index2: number) => {
const tmp = cardList[index1];
cardList[index1] = cardList[index2];
cardList[index2] = tmp;
setCardList([...cardList]);
}, [])
return <div className="card-list">
{
cardList.map((item: CardItem, index) => (
<Card data={item} key={'card_' + item.id} index={index} swapIndex={swapIndex}/>
))
}
</div>
}
export default App;
css:
css
.card {
width: 200px;
line-height: 60px;
padding: 0 20px;
border: 1px solid #000;
background: skyblue;
margin: 10px;
cursor: move;
}
.dragging {
border-style: dashed;
background: #fff;
}
最后我们来实现个低代码的编辑器。
这种拖拽修改布局的功能,有实现思路么?
其实做完拖拽排序后你应该就有思路了。
这个不也是拖拽到可以 drop 到区域的时候,触发 hover 事件,然后改变数据触发重新渲染么?
只不过上面的案例是修改数组的元素顺序,而这个是修改一个树形 json 的元素顺序。
我们来写写看:
新建个 App3.tsx
javascript
import { useState } from "react";
import './App3.css';
const ROW = 'row';
const COLUMN = 'column';
const COMPONENT = 'component';
interface LayoutItem {
type: string;
id: string;
children?: LayoutItem[];
component?: {
type: string;
}
}
const initialLayout = [
{
type: ROW,
id: "row0",
children: [
{
type: COLUMN,
id: "column0",
children: [
{
type: COMPONENT,
id: "component0",
component: {
type: 'aaa'
}
},
{
type: COMPONENT,
id: "component1",
component: {
type: 'bbb'
}
}
]
},
{
type: COLUMN,
id: "column1",
children: [
{
type: COMPONENT,
id: "component2",
component: {
type: 'aaa'
}
}
]
}
]
},
{
type: ROW,
id: "row1",
children: [
{
type: COLUMN,
id: "column2",
children: [
{
type: COMPONENT,
id: "component3",
component: {
type: 'bbb'
}
},
{
type: COMPONENT,
id: "component0",
component: {
type: 'aaa'
}
},
{
type: COMPONENT,
id: "component2",
component: {
type: 'bbb'
}
}
]
}
]
}
];
interface ComponentProps {
data: LayoutItem
}
function Component(compProps: ComponentProps) {
const { component } = compProps.data;
return <div className="component">
{
component?.type
}
</div>
}
interface ColumnProps {
data: LayoutItem;
}
function Column(columnProps: ColumnProps) {
const { children } = columnProps.data;
return <div className="column">
{
children?.map(item => {
return <Component key={`comp_id_${item.id}`} data={item}></Component>
})
}
</div>
}
interface RowProps {
data: LayoutItem
}
function Row(rowProps: RowProps) {
const { children } = rowProps.data;
return <div className="row">
{
children?.map(item => {
return <Column key={`col_id_${item.id}`} data={item}></Column>
})
}
</div>
}
function App() {
const [layout, setLayout] = useState<LayoutItem[]>(initialLayout);
return <div className="container">
{
layout.map(item => {
return <Row key={`row_id_${item.id}`} data={item}></Row>;
})
}
</div>
}
export default App;
比较容易看懂,就是 Row、Column、Component 的三层渲染。
css 如下:
css
.row {
padding: 10px;
border: 1px solid #000;
display: flex;
}
.column{
flex: 1;
padding: 10px;
border: 1px solid blue;
}
.column:not(:last-child) {
margin-right: 10px;
}
.component {
height: 60px;
margin: 10px;
border: 1px dashed #000;
}
把它渲染出来:
是这样的:
这里我们没有直接渲染对应的组件,只是显示了组件类型:
我们加几个组件:
javascript
function Aaa() {
return <button>aaa</button>
}
function Bbb() {
return <img alt="哆啦A梦" width="50" height="50" src="https://img0.baidu.com/it/u=3610760552,2286123102&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500"/>
}
function Ccc() {
return <input type="range"></input>
}
const registeredComponent: Record<string, any> = {
aaa: Aaa,
bbb: Bbb,
ccc: Ccc
}
在 Component 里把它们渲染出来:
javascript
function Component(compProps: ComponentProps) {
const { component } = compProps.data;
const Comp = registeredComponent[component!.type];
return <div className="component">
{
<Comp></Comp>
}
</div>
}
然后在编辑器下面添加个候选组件区:
javascript
interface BarItemProps {
type: string;
}
function BarItem(props: BarItemProps) {
const Comp = registeredComponent[props.type];
return <div className="bar-item">
<Comp></Comp>
</div>
}
html
<div className="bottomBar">
<BarItem type="aaa"></BarItem>
<BarItem type="bbb"></BarItem>
<BarItem type="ccc"></BarItem>
</div>
写下样式:
css
.bottomBar {
border: 1px solid #000;
display: flex;
}
.bottomBar .bar-item {
border: 1px solid blue;
margin: 10px;
}
渲染出来是这样的:
布局写完之后,我们想一下:
什么元素可以 drag ,什么元素可以 drop 呢?
很明显,Row、Column、Component、SideBar 都可以 drag。
而可以 drop 的是空白的区域。
我们先分别给 Row、Column、Component、SideBar 加上 useDrag
这里给元素添加拖拽,可以直接把 useDrag 返回的第二个值作为 ref 参数的值,这种写法比之前的方式更简便一些。
这样这些元素就都可以拖拽了:
不过现在看着不清晰,我们给加个白色背景:
清晰多了。
然后空白区域可以 drop,但是现在我们空白区域是用 margin 隔开的,所以我们也得换成一个组件。
写个空白区域的组件:
javascript
interface DropZoneProps {
className: string;
}
function DropZone(props: DropZoneProps) {
return <div className={`drop-zone ${props.className}`}></div>
}
添加对应的样式:
css
.drop-zone {}
.drop-zone-horizental {
height: 20px;
}
.drop-zone-vertical {
width: 20px;
}
把前面设置的 margin 去掉:
我们换成 DropZone 组件:
这样空白就是 DropZone 组件了:
打开 devtools 看一下:
确实,DropZone 加到了正确的位置。
然后就可以在 DropZone 里加上 drop 的处理了:
javascript
function DropZone(props: DropZoneProps) {
const [{ overing }, drop] = useDrop({
accept: ['column', 'row', 'component', 'barItem'],
drop(item) {
console.log(item);
},
collect(monitor) {
return {
overing: monitor.isOver()
}
}
});
return <div ref={drop} className={`drop-zone ${props.className} ${overing ? 'focus' : ''}`}></div>
}
加上 useDrop,在 drop 的回调里打印下 item 的值。
并且在 collect 里通过 monitor 拿到是否 over,是的话就显示 focus 的 class。
我们加上 focus 的样式:
css
.drop-zone.focus {
background: blue;
}
是不是有低代码编辑器的感觉了?
现在只差最后一步了:根据 drag 的元素和 drop 的位置,修改数据,触发重新渲染。
怎么知道什么元素从哪里拖拽到了哪里呢?
之前拖拽排序的时候比较简单,知道 index 然后互换位置就行。
现在变成三层的了,那是不是 3 个 index 就可以呢?
比如第 0 行第 0 列,第 0 个组件就是 0-0-0。
第 2 行第 0 列,第 1 个组件就是 2-0-1。
我们把这个叫做路径 path。
有了这个,不就知道元素从哪里移动到了哪里么?
所以我们渲染的时候要把 index 传入:
然后 useDrag 传递的 item 数据要带上 path:
同理,渲染 Column 组件的时候要带上 row 和 column 的 index:
渲染 Component 组件的时候也是:
测试下:
分别拖拽了 3 个组件,path 都是对的。
我又测试了下 row 和 column 的拖拽,path 也是对的:
这样,我们就还差一个数据:拖拽到了什么位置。
显然,这里也是通过 path 标识。
在渲染 DropZone 的时候,也给它加上临近组件的 path:
然后我们在 drop 的时候打印下 item 和 path:
就知道从哪里拖拽到了哪里。
如图,我们把 1-0-0 的组件,拖拽到了 0-0-2 的位置。
接下来只要修改 layout,触发重新渲染就好了。
我们通过 context 来传递这个 swapPosition 方法:
在 DropZone 里取出来:
测试下:
调用成功了。
而这个函数里是可以拿到 layout 和 setLayout 的。
接下来只要修改 layout 之后调用下 setLayout 就好了。
具体的情况还是比较多的,这里就不全部实现了。
我们这里写死几种情况测试下:
比如从 1-0-0 拖拽到 0-1-1 的情况:
javascript
const swapPosition = useCallback((item: any, path2: string) => {
const lay = layout as any;
lay[1].children[0].children.splice(0, 1);
lay[0].children[1].children.splice(1, 0, item.data);
setLayout([...lay])
}, []);
然后从 0-0-1 到 0-1 的情况:
javascript
const swapPosition = useCallback((item: any, path2: string) => {
const lay = layout as any;
lay[0].children[0].children.splice(1, 1);
lay[0].children.splice(1, 0, {
type: COLUMN,
children: [
item.data
]
}
);
setLayout([...lay])
}, []);
这种需要在外层补一个 column。
然后是从 bottomBar 拖到 1-0-2 的情况:
javascript
const swapPosition = useCallback((item: any, path2: string) => {
const lay = layout as any;
lay[1].children[0].children.splice(2, 0, {
type: COMPONENT,
component: {
type: item.type
}
}
);
setLayout([...lay])
}, []);
通用的处理逻辑就是情况比较多,但是一样的实现思路。
这样,我们就实现了一个低代码的编辑器,对 react-dnd 算是有了比较好的掌握了。
这个案例的全部代码如下:
javascript
import React, { Fragment, useCallback, useContext, useState } from "react";
import './App3.css';
import { useDrag, useDrop } from "react-dnd";
const ROW = 'row';
const COLUMN = 'column';
const COMPONENT = 'component';
interface LayoutItem {
type: string;
id: string;
children?: LayoutItem[];
component?: {
type: string;
}
}
const initialLayout = [
{
type: ROW,
id: "row0",
children: [
{
type: COLUMN,
id: "column0",
children: [
{
type: COMPONENT,
id: "component0",
component: {
type: 'aaa'
}
},
{
type: COMPONENT,
id: "component1",
component: {
type: 'bbb'
}
}
]
},
{
type: COLUMN,
id: "column1",
children: [
{
type: COMPONENT,
id: "component2",
component: {
type: 'aaa'
}
}
]
}
]
},
{
type: ROW,
id: "row1",
children: [
{
type: COLUMN,
id: "column2",
children: [
{
type: COMPONENT,
id: "component3",
component: {
type: 'bbb'
}
},
{
type: COMPONENT,
id: "component0",
component: {
type: 'aaa'
}
},
{
type: COMPONENT,
id: "component2",
component: {
type: 'bbb'
}
}
]
}
]
}
];
function Aaa() {
return <button>aaa</button>
}
function Bbb() {
return <img alt="哆啦A梦" width="50" height="50" src="https://img0.baidu.com/it/u=3610760552,2286123102&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500"/>
}
function Ccc() {
return <input type="range"></input>
}
const registeredComponent: Record<string, any> = {
aaa: Aaa,
bbb: Bbb,
ccc: Ccc
}
interface ComponentProps {
data: LayoutItem,
rowIndex: number;
columnIndex: number;
compIndex: number;
}
function Component(compProps: ComponentProps) {
const { component } = compProps.data;
const currentPath = `${compProps.rowIndex}-${compProps.columnIndex}-${compProps.compIndex}`;
const [, drag] = useDrag({
type: 'component',
item: {
type: 'component',
path: currentPath,
data: compProps.data
}
});
const Comp = registeredComponent[component!.type];
return <div ref={drag} className="component">
{
<Comp></Comp>
}
</div>
}
interface ColumnProps {
data: LayoutItem;
columnIndex: number;
rowIndex: number;
}
function Column(columnProps: ColumnProps) {
const { children } = columnProps.data;
const currentPath = `${columnProps.rowIndex}-${columnProps.columnIndex}`
const [, drag] = useDrag({
type: 'column',
item: {
type: 'column',
path: currentPath,
data: columnProps.data
}
});
return <div ref={drag} className="column">
{
children?.map((item, index) => {
return <Fragment>
<DropZone className="drop-zone-horizental" path={`${currentPath}-${index}`}></DropZone>
<Component key={`comp_id_${item.id}`} data={item}
rowIndex={columnProps.rowIndex}
columnIndex={columnProps.columnIndex}
compIndex={index}
></Component>
</Fragment>
})
}
<DropZone className="drop-zone-horizental" path={`${currentPath}-${children?.length}`}></DropZone>
</div>
}
interface RowProps {
data: LayoutItem,
rowIndex: number;
}
function Row(rowProps: RowProps) {
const { children } = rowProps.data;
const currentPath = rowProps.rowIndex + '';
const [, drag] = useDrag({
type: 'row',
item: {
path: currentPath,
type: 'row',
data: rowProps.data
}
});
return <div ref={drag} className="row">
{
children?.map((item, index) => {
return <Fragment>
<DropZone className="drop-zone-vertical" path={`${currentPath}-${index}`}></DropZone>
<Column key={`col_id_${item.id}`} data={item} rowIndex={rowProps.rowIndex} columnIndex={index}></Column>
</Fragment>
})
}
<DropZone className="drop-zone-vertical" path={`${currentPath}-${children?.length}`}></DropZone>
</div>
}
interface BarItemProps {
type: string;
}
function BarItem(props: BarItemProps) {
const Comp = registeredComponent[props.type];
const [, drag] = useDrag({
type: 'barItem',
item: props
});
return <div ref={drag} className="bar-item">
<Comp></Comp>
</div>
}
interface DropZoneProps {
className: string;
path: string;
}
function DropZone(props: DropZoneProps) {
const { swapPosition } = useContext(LayoutContext);
const [{ overing }, drop] = useDrop({
accept: ['column', 'row', 'component', 'barItem'],
drop(item: any) {
swapPosition(item, props.path);
},
collect(monitor) {
return {
overing: monitor.isOver()
}
}
});
return <div ref={drop} className={`drop-zone ${props.className} ${overing ? 'focus' : ''}`}></div>
}
type ContextType = { swapPosition: Function };
const LayoutContext = React.createContext<ContextType>({
swapPosition: () => {}
});
function App() {
const [layout, setLayout] = useState<LayoutItem[]>(initialLayout);
const swapPosition = useCallback((item: any, path2: string) => {
// const lay = layout as any;
// lay[1].children[0].children.splice(0, 1);
// lay[0].children[1].children.splice(1, 0, item.data);
// setLayout([...lay])
// const lay = layout as any;
// lay[0].children[0].children.splice(1, 1);
// lay[0].children.splice(1, 0, {
// type: COLUMN,
// children: [
// item.data
// ]
// }
// );
// setLayout([...lay])
const lay = layout as any;
lay[1].children[0].children.splice(2, 0, {
type: COMPONENT,
component: {
type: item.type
}
}
);
setLayout([...lay])
}, []);
return <LayoutContext.Provider value={{
swapPosition
}}>
<div className="container">
{
layout.map((item, index) => {
return <Fragment>
<DropZone className="drop-zone-horizental" path={`${index}`}></DropZone>
<Row key={`row_id_${item.id}`} data={item} rowIndex={index}></Row>
</Fragment>
})
}
<DropZone className="drop-zone-horizental" path={`${layout.length}`}></DropZone>
<div className="bottomBar">
<BarItem type="aaa"></BarItem>
<BarItem type="bbb"></BarItem>
<BarItem type="ccc"></BarItem>
</div>
</div>
</LayoutContext.Provider>
}
export default App;
css:
css
.row {
padding: 10px;
border: 1px solid #000;
display: flex;
background: #fff;
}
.column{
flex: 1;
padding: 10px;
border: 1px solid blue;
background: #fff;
}
.component {
height: 60px;
border: 1px dashed #000;
background: #fff;
}
.bottomBar {
border: 1px solid #000;
display: flex;
}
.bottomBar .bar-item {
border: 1px solid blue;
margin: 10px;
background: #fff;
}
.drop-zone {
}
.drop-zone-horizental {
height: 20px;
}
.drop-zone-vertical {
width: 20px;
}
.drop-zone.focus {
background: blue;
}
总结
我们学了 react-dnd 并用它实现了 3 个案例。
react-dnd 主要就是 useDrag、useDrop、useDragLayout 这 3 个 API。
useDrag 是给元素添加拖拽,指定 item、type、collect 等参数。
useDrop 是给元素添加 drop,指定 accepet、drop、hover、collect 等参数。
useDragLayout 是自定义预览,可以通过 monitor 拿到拖拽的实时位置。
此外,最外层还要加上 DndProvider,用来组件之间传递数据。
其实各种拖拽功能的实现思路比较固定:什么元素可以拖拽,什么元素可以 drop,drop 或者 hover 的时候修改数据触发重新渲染就好了。
拖拽排序就是 hover 的时候互换两个 index 的对应的数据,然后 setState 触发渲染。
低代码编辑器则是要记录两个 path,根据两个 path 来判断怎么修改数据触发渲染。
用 react-dnd,我们能实现各种基于拖拽的功能。