最近在做一个拖拽生成H5页面的项目。期间做了一些调研,也看过一些文档,在这里做个记录,方便之后回忆。
1. Drag原生事件
首先,拖拽组件本质上就是在调整DOM的顺序、结构。drag事件是浏览器事件中的一种。不仅如此还包含了拖拽过程中的事件节点,这里我想把他们称之为拖拽过程中的生命周期。
- dragstart
- drag
- dragenter
- dragover
- dragleave
- dragend
在做拖拽的相关的需求之前,建议先将MDN
中的原生api
阅读一下。一来了解一下拖拽的"庐山真面目",二来也感受一下浏览器的边界能力在哪里。
因为react-dnd,react-beautiful-dnd不过是对浏览器的能力做了一层封装。而这种库的介绍文档太长不容易在短时间内了解其所有功能。
2. React-dnd 库
首先来看一下React-dnd
库的自我介绍
React DnD 是一组 React 实用程序,可帮助您构建复杂的拖放界面,同时保持组件解耦。它特别适用于 Trello 和 Storify 这种应用,在拖拽的同时可以进行数据传输,并且改变组件的样式和状态。
抓重点!数据传输
、状态
2.1 一些特性(了解Rect-dnd的设计理念)
- React-dnd的操作对象是components
- React-dnd的数据流是单项的(其实是借鉴了Redux)
- React-dnd帮你抹平了浏览器的兼容性问题
- React-dnd支持拓展和测试(默认是使用HTML5的drag和dorp事件,不过你也可以自定义“backend”;支持在Node环境下测试组件的交互)
2.2 一些概念
-
Items
一个Item就是一个用来描述正在被拖拽内容的普通JavaScript对象;当你拖拽一个
banner
组件时,Item的内容可能就是这样的:{ type: "BANNER", version: "1.0.0", componentName: "banner", id: "BANNER_1-0-0_uuid1630686650775gavonSbf", isBussinessComponent: false, }
-
Types
类型是一个字符串(或符号),它唯一地标识了应用程序中的整个项目类。类型很有用,因为随着您的应用程序的增长,您可能希望使更多内容可拖动,但您不一定希望所有现有的放置目标突然开始对新项目做出反应。 这些类型允许您指定兼容的拖放源和放置目标。
-
Monitors
拖拽本质上是有状态的。拖拽这个动作要么正在进行,要么不在进行(dragging),当前时刻要么存在被拖拽的元素(item)和元素类型(type),要么没有。
React-dnd
通过Monitors
的形式将内部的状态暴露给组件。当drag
和drop
事件的状态发生改变,component 就可以对更新后的props做出相应的改变。假设你想当A组件被拖拽的时候,B区域显示高亮的效果,那么就可以给B区域组件传一个
collect
函数。(monitor具体的API后面会介绍)function collect(connect, monitor) { return { highlighted: monitor.canDrop(), hovered: monitor.isOver() } }
-
connectors
假设我们的"后端"是基于
DOM Events
来处理的,就像我们 1. Drag原生事件 中提到的那样。但是我们用于描述DOM的本质上是JSX,那么React-dnd
的"后台"在处理时怎么知道哪个DOM是需要添加dragable=true
的呢?connectors
允许你给你的组件预定义一个角色,有一下三种:-
drag source 拖拽源(比如我们的组件物料)
-
drag preview 拖放预览
-
drop target 拖放目标
假设我们下面来定义一个允许
放置拖拽目标
的组件,首先在collect函数中拿到connect
-
function collect(connect, monitor) {
return {
highlighted: monitor.canDrop(),
hovered: monitor.isOver(),
connectDropTarget: connect.dropTarget()
}
}
然后我们就可以在组件的render函数中拿到从monitor中传下来的props,并且JSX用connectDropTarget
包括起来用于drag、drop、dragover等原生事件的监听。
render() {
const { highlighted, hovered, connectDropTarget } = this.props;
return connectDropTarget(
<div className={classSet({
'Cell': true,
'Cell--highlighted': highlighted,
'Cell--hovered': hovered
})}>
{this.props.children}
</div>
);
}
- Drag Sources and Drop Targets
相信从上面的例子中,你已经体会到了拖放目标的基本用法。这里我们再来详细的讲述一下。
如果你希望React-dnd
来帮你管理一些拖拽中的状态,使你的组件拥有可拖、可放置、可预览、甚至是可拖可放(比如调整顺序)的能力,那么需要以下4要素。
- types 用来描述正在被拖拽内容的普通JavaScript对象
- items 对组件的描述
- side effects 拖拽过程中的一些生命周期函数
- collecting function(monitor) 给组件传递一些拖拽过程中组件内部需要感知的属性
那么一个被React-dnd
包装的组件可能就长这样
import { DragSource } from 'react-dnd';
function collectFunc(connect, monitor) {
return {
connectDragSource: connect.dragSource(),
isDragging: monitor.isDragging(),
};
}
const cardMonitors = {
isDragging(props, monitor) {
return monitor.getItem().id === props.id;
},
// 在这个函数中构造被拖拽函数的数据结构
beginDrag(props) {
return {
id: props.id,
};
},
// 在endDrag中判断是否成功添加元素,但是endDrag感觉有延迟,会等待一段时间才执行
endDrag(props, monitor) {
console.log('endDrag');
}
};
// ...
export default DragSource('box', cardMonitors, collectFunc)(DragableWrapper)
一个既能拖又能放的组件可能就长这样
import { DragSource, DropTarget } from 'react-dnd';
//...
(DropTarget(
'box',
dropCardSource,
(connect) => ({
connectDropTarget: connect.dropTarget(),
}),
)(
DragSource(
'box',
dragCardSource,
(connect, monitor) => ({
connectDragSource: connect.dragSource(),
isDragging: monitor.isDragging(),
}),
)(<DragableAndDropableWrapper />),
)
- Backends
React-dnd
默认使用HTML5 drag and drop API作为 Backends 的默认值。但是HTML5 drag and drop API
也存在一些缺陷:在以 touch 事件为主的屏幕上是不支持的,这也是为什么在React-dnd
中将HTML5 drag and drop API
设计成成可插拔的。你也可以基于 touch 事件、mouse 事件来对组件的拖拽事件进行实现,由拖拽事件组成的插件在React-dnd
就称为 backends。目前官方列出的插件
- react-dnd-html5-backend (默认)
- react-dnd-touch-backend (对平板、移动设备的事件支持)
- react-dnd-test-backend (用做测试)
3. 搭建拖拽平台
首先,参照开源项目搭建基本的页面结构
3.1 组件可拖拽
接下来的工作,就是让我们左边的组件物料可拖拽。参照react-dnd
的思路,我们需要将组件用dragSource
包裹一下。因此我们写一个高阶函数专门给组件提供拖拽的能力。
import React, { Component } from 'react';
import { DragSource } from 'react-dnd';
// 将事件的状态变化通知给组件
function collectFunc(connect, monitor) {
return {
connectDragSource: connect.dragSource(),
isDragging: monitor.isDragging(),
};
}
// 当事件状态发生改变触发的函数
const cardMonitors = {
isDragging(props, monitor) {
return monitor.getItem().id === props.id;
},
beginDrag(props) {
const item = {
id: props.id,
// 还可以自定义其他参数
};
return item;
},
// 在endDrag中判断是否成功添加元素,但是endDrag感觉有延迟,会等待一段时间才执行
endDrag(props, monitor) {
// 取消dragging
}
};
class DragableWrapper extends Component {
constructor(props) {
super(props);
this.state = { };
}
render() {
const { child, key, onClick } = this.props;
return (
<div key={key} className="dragable-wrapper" onClick={onClick}>
{this.props.connectDragSource(
child,
)}
</div>
);
}
}
export default DragSource('box', cardMonitors, collectFunc)(DragableWrapper);
注意事项:
- 首先预留一个child作为组件的展示
- 引入
react-dnd
中的DragSource,接受三个参数,分别是 1.types 标识了该组件的项目 2. monitors 监视器用于拖拽过程中时间的监听 3. connectors 将拖拽过程中状态的变化反馈给组件 - monitors中的
beginDrag
的返回值用于标识拖拽过程中需要携带的数据。
所以,我们左边的组件在渲染时,大概是这个样子。
import { DragableWrapper } from './components'
// ...
renderComponent = (componentList) => {
return componentList.map((item) => {
const uid = uuid();
return (
// 传下去的props 需要在 beginDrag 的时候return 才能在事件中拿到相应的数据
<DragableWrapper
key={uid}
child={
<div>
<span>{item.componentName}</span>
</div>
}
/>
);
})
}
render() {
return <div>{this.renderComponents(componentList)}</div>
}
可以看到,组件左边的组件目前已经是可拖拽的了。
3.2 预览区域可放置内容
和dragSource原理差不多
,预览区域是可以放置的区域,所以这里的预览区域需要将组件用dropSource
包裹一下。进一步考虑:之后预览区域的内容也是可拖拽的,所以需要将这个高阶组件用dropSource
和dragSource
同时包裹一下。
import React, { Component } from 'react';
import { DragSource, DropTarget } from 'react-dnd';
import './index.less';
const dragCardSource = {
isDragging(props, monitor) {
return monitor.getItem().id === props.id;
},
// 在这个函数中构造被拖拽函数的数据结构
// beginDrag(props, monitor, component) {
beginDrag(props) {
// Return the data describing the dragged item
const item = {
id: props.id,
};
return item;
},
};
const dropCardSource = {
hover(props, monitor, component) {
if (!component) {
return null;
}
const item = monitor.getItem(); // item标识正在被做拖拽的元素
// 从容器外拖拽过来的情况 (根据是否生成了id 和 index进行判断)
if (!item.id && !item.index) {
addedComponent.splice(props.index, 0, item); // 在hover元素之前放置drag元素
}
},
};
class DragableAndDropableWrapper extends Component {
constructor(props) {
super(props);
// this.myRef = React.createRef();
this.state = {};
}
// static getNode = this.getNode;
// getNode = () => this.myRef.current;
render() {
const {
forwardedRef,
child,
key,
connectDropTarget,
connectDragSource,
className,
isDragging,
onClick,
} = this.props;
const opacity = isDragging ? 0 : 1;
return connectDropTarget(
connectDragSource(
<div
ref={forwardedRef}
key={key}
className={`drag-drop-wrapper ${className}`}
style={{ opacity }}
onClick={onClick}
>
{child}
</div>
)
);
}
}
const RefHOC = React.forwardRef((props, ref) => (
<DragableAndDropableWrapper {...props} forwardedRef={ref} />
));
export default DropTarget('box', dropCardSource, (connect) => ({
connectDropTarget: connect.dropTarget(),
}))(
DragSource('box', dragCardSource, (connect, monitor) => ({
connectDragSource: connect.dragSource(),
isDragging: monitor.isDragging(),
}))(RefHOC)
);
关键步骤
-
在
dropSource
的监听器(dragCardSouce函数)中监听hover事件,当左边拖拽的元素hover到可放置区域,就将组件添加到预览区域中。(当然也有可能存在hover了但是没有在放置区域放下的情况) -
在被拖拽的组件监视
endDrag
行为,如果没有拖拽放置成功,则从预览区域中删除本次添加的元素(可以通过redux实现)// 当事件状态发生改变触发的函数 const cardMonitors = { isDragging(props, monitor) { return monitor.getItem().id === props.id; }, beginDrag(props) { const item = { id: props.id, // 还可以自定义其他参数 }; return item; }, // 在endDrag中判断是否成功添加元素,但是endDrag感觉有延迟,会等待一段时间才执行 endDrag(props, monitor) { if (!monitor.didDrop()) { // drop 不成功 将拖拽添加的元素退回 const { addedComponent, draggingId } = props; props.setValues({ addedComponent: addedComponent.filter((item) => item.id !== draggingId), draggingId: "", }); } else { // 如果 drag && drop 成功,设置默认的active compnent props.setValues({ activeComInfo: monitor.getItem(), }); } }, };
3.3 预览区域可放置内容
接着上面的思路,我们在调整我们的顺序时,同样也是监听hover事件,这个时候我们能拿到两个下标dragIndex
, hoverIndex
。dragIndex
表示当前正在拖拽的组件下标,hoverIndex
表示当前hover的下标。我们设定当拖拽组件的位移超过hover组件的中间位置时,开始交换位置(是否有更好的交互方式?)。那么我们就需要通过获取getBoundingClientRect
这个API来获取当前元素的位置,当然,前提是我们需要先拿到真实的DOM节点。具体代码如下:
hover(props, monitor, component) {
...
const node = component;
if (!node) {
return null;
}
const hoverBoundingRect = node.getBoundingClientRect();
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
const clientOffset = monitor.getClientOffset();
// Get pixels to the top
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
return;
}
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
return;
}
// 在这里交换位置!!
props.moveComponnet(dragIndex, hoverIndex);
// 由于交换了位置,需要手动设置一下monitor中的index
monitor.getItem().index = hoverIndex;
}
注:component拿到的不一定是真实的DOM,这取决于你用的是函数组件、类组件或是其他高阶组件。
3.4 支持直接将组件拖到预览区域的任意位置
到目前为止,我们已经实现了拖放、排序功能。目前拖拽进来的元素仅仅是简单的push到数组的最后一个位置,体验不佳,我们希望的是拖拽进来的元素能插入到预览区域的任意位置。其实相当于将3.2、3.3中的功能组合起来。我们同样还是在hover的时候做处理。
if (props.materialDragging && ![dragHoverIndex].includes(props.index)) {
const { draggingId } = props;
item.id = draggingId;
// 先将插进去的元素过滤掉, 然后在新的位置插入一个新的元素 (但draggingId不需要重新生成)
const newAddedComponnet = cloneDeep(
props.addedComponent.filter((item) => item.id !== draggingId)
);
newAddedComponnet.splice(props.index, 0, item); // 在hover元素之前放置drag元素
props.setRematchValues({
addedComponent: newAddedComponnet,
draggingId: item.id,
materialDragging: true, // 标识当前是否在拖拽
dragHoverIndex: props.index,
});
monitor.getItem().index = props.index; // 由于交换了位置,需要手动设置一下monitor中的index
}