1、react-dnd 简介
React DnD 是 React 和 Redux 的核心作者 Dan Abramov 创造的一组 React 高阶组件,可以在保持组件分离的前提下帮助构建复杂的拖放接口。
React DnD 是一组 React 高阶组件,使用的时候只需要使用对应的 API 将目标组件进行包裹,即可实现拖动或接受拖动元素的功能。将拖动的事件转换成对象中对应状态的形式,不需要开发者自己判断拖动状态,只需要在传入的 spec 对象中各个状态属性中做对应处理即可。
2、react-dnd 基本概念
Backends
React DnD 抽象了后端的概念,可以使用 HTML5 拖拽后端,也可以自定义 touch、mouse 事件模拟的后端实现,后端主要用来抹平浏览器差异,处理 DOM 事件,同时把 DOM 事件转换为 React DnD 内部的 redux action。
Item
React DnD 基于数据驱动,当拖放发生时,它用一个数据对象来描述当前的元素,比如 { cardId: 25 }。
Type
type是唯一标识应用程序中整个项目类别的字符串(或符号),类似于 redux 里面的 actions types 枚举常量
Monitors
拖放操作都是有状态的,React DnD 通过 Monitor 来存储这些状态并且提供查询。
Connectors
Backend 关注 DOM 事件,组件关注拖放状态,connector 可以连接组件和 Backend ,可以让 Backend 获取到 DOM。
useDrag
用于将当前组件用作拖动源的钩子。
useDrop
使用当前组件作为放置目标的钩子。
useDragLayer
用于将当前组件用作拖动层的钩子。
3、使用案例
3.1 DndProvider 注入
首先使用 DndProvider 包裹拖拽的区域,只有DndProvider 包裹的区域才能使用 react-dnd 拖拽功能。使用 DndProvider 包裹区域还需要将其注释后端。
只有的子孙元素才能使用 react-dnd。
import { DndProvider } from "react-dnd";
import HTML5Backend from 'react-dnd-html5-backend';
<DndProvider backend={HTML5Backend}>
<div className="list">
{
list.map((item, index) => {
return <Item key={item} item={item} index={index} type={types.item} list={list} setList={setlist} />;
})
}
</div>
</DndProvider>
DndProvider api
- backend: 必填,dnd后端可以使用官方的提供的两个 HTML5Backend or TouchBackend,或者也可以自己写backend后端。
- context: 选填,用户配置后端的上下文,这取决于后端的实现。
- options: 配置后端对象,自定义时可以传入backend。
3.2 useDrag 声明拖动源
可以拖动的组件必须用 useDrag 声明。
const [{ isDragging }, drag] = useDrag({
item: {
type,
item,
index,
},
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
规范对象属性
- type: 必填,字符串或符号,只有相同注册类型的放置目标才能与当前目标相互拖拽。
- item: 必填,(对象或函数)。
当是一个对象时,它是一个描述被拖动数据的纯 JavaScript 对象。是拖放目标唯一可用的关于拖动源的信息,因为它会耦合拖动源和放置目标,所以对象的属性数据越少越好。
当是一个函数时,在拖动操作开始时被触发并返回一个表示拖动操作的对象。如果返回null,则取消拖动操作。 - previewOptions: 选填,一个描述拖动预览选项的纯 JavaScript 对象。
- options: 选填。
- end(item, monitor) : 选填。当拖动停止时,end调用。对于每个 begin,都有一个对应的 end 被调用。可以调用 monitor.didDrop() 以检查该放置是否由兼容的放置目标处理。如果它已被处理,并且放置目标通过从其方法返回一个普通对象来指定放置结果drop(),则它将作为monitor.getDropResult()。
- canDrag(monitor) : 选填。使用它来指定当前是否允许拖动。不填会默认一直可以被拖动。注意:不能在这个方法内部调用monitor.canDrag()。
- isDragging(monitor) : 选填。默认情况下,只有发起拖动操作的拖动源才被认为是可拖动的。isDragging 可以通过定义自定义方法来覆盖此行为。它可能会返回类似 props.id === monitor.getItem().id. 注意:你不能在这个方法内部调用monitor.isDragging()
- collect: 选填。采集功能。它返回 props 的普通对象以注入到组件中。接收两个参数,monitor和props
3.3 useDrop 声明放置源
useDrop 用于声明放置源。
// useDrop 的accept 和 useDrag 的type相同才能相互拖拽
const [, drop] = useDrop({
accept: [type],
collect: () => ({}),
hover(item, monitor) {
// 拖拽的索引
const dragIndex = item.index;
// 放置目标的索引
const hoverIndex = index;
// 如果拖拽目标和放置目标相同就return
if (dragIndex === hoverIndex) return;
const { top, bottom } = ref.current.getBoundingClientRect();
// 放置目标一半的高度
const halfHoverHeigth = (bottom - top) / 2;
const { y } = monitor.getClientOffset();
const hoverClientY = y - top;
// 当移动到放置目标一半时切换位置
if (
(dragIndex < hoverIndex && hoverClientY > halfHoverHeigth) ||
(dragIndex > hoverIndex && hoverClientY < halfHoverHeigth)
) {
const dragItem = list[dragIndex];
list.splice(dragIndex, 1);
list.splice(hoverIndex, 0, dragItem);
setList([...list]);
item.index = hoverIndex;
}
},
});
规范对象属性
- accept: 必填。字符串、符号或两者的数组。必须与 useDrag 声明的拖动源的type 保持一致。
- options: 选填。一个普通的对象。
- drop(item, monitor): 选填。当拖拽的组件掉落在目标上时调用。可以返回未定义或普通对象。如果返回一个对象,它将成为放置结果 endDrag,并且在其方法中可使用 monitor.getDropResult()。drop 如果有嵌套的放置目标,可以通过 monitor.didDrop()和来检测嵌套目标是否已经处理。
- hover(item, monitor): 选填。当项目悬停在组件上时调用。可以通过monitor.isOver({ shallow: true }) 来测试悬停是仅发生在当前目标上还是嵌套目标上。可以通过 monitor.canDrop() 来测试是否可以放置。
- canDrop(item, monitor): 选填。使用它来指定放置目标是否能够接受该项目。注意:不能在这个方法内部调用 monitor.canDrop()。
4、react-grid-layout 简介
react-grid-layout 是基于 react 的网格布局系统,支持视图的拖拽和缩放。
react-grid-layout 将可视区域横向分为可自定义列数的栅格,可视区域内的组件可以进行拖拽和缩放,组件的高度和宽度都是栅格宽高的n倍。
4.1 基本使用
react-grid-layout 组件的使用笔记简单,只需将其包裹组件区域,区域内的组件即可实现可拖拽、可缩放和栅格布局。
import GridLayout from 'react-grid-layout';
// 可拖拽组件列表
const layout = [
{i: 'a', x: 0, y: 0, w: 1, h: 2, static: true},
{i: 'b', x: 1, y: 0, w: 3, h: 2, minW: 2, maxW: 4}, {i: 'c', x: 4, y: 0, w: 1, h: 2}
];
<GridLayout className="layout" layout={layout} cols={12} rowHeight={30} width={1200}>
<div key="a">a</div>
<div key="b">b</div>
<div key="c">c</div>
</GridLayout>
API
- layout: 必填,数组。layout 用来设置 GridLayout 内子组件的展示位置及大小。
i: 用于匹配子组件的唯一值;
x: 组件的位置横坐标,用栅格的位置表示,不能是px;
y: 组件的位置纵坐标,用栅格的位置表示,不能是px;
w、h: 组件的宽度、高度,均用所占栅格数表示,不能是px;
static: 静态,置为 true 则该组件不能被拖动;
minW、maxW: 组件可缩放的最小宽度和最大宽度; - cols: 画板内宽度表示的栅格数,默认为12,可设置为12的倍数。
- rowHeight: 栅格的行高。
- width: 画板所占的宽度(px值)。
4.2 响应式布局
要支持不同平台、多端共用的响应式布局,可以使用 Responsive 响应式组件。具体用法如下:
import { Responsive as ResponsiveGridLayout } from "react-grid-layout";
const pointsObj = {lg: 1200, md: 996, sm: 768, xs: 480};
const colsObj = {lg: 12, md: 10, sm: 6, xs: 4};
<ResponsiveGridLayout
className="layout"
layouts={layouts}
breakpoints={pointsObj}
cols={colsObj}
>
<div key="1">1</div>
<div key="2">2</div>
<div key="3">3</div>
</ResponsiveGridLayout>
使用响应式布局时,breakpoints 必须与设置的 cols 一一对应,breakpoints 表示在不同大小的屏幕下画板所占的宽度。
cols 设置对象表示在不同大小的屏幕下横向设置的栅格数。
5、拖拽布局的实现
实现思路
首先使用 react-dnd 实现从右侧卡片列表拖拽到左侧面板中的功能,再通过 react-grid-layout 实现卡片的栅格布局。
实现步骤
1、拆分拖动源和放置面板组件单独使用
定义拖动源组件,卡片列表的每一个卡片即为拖动源组件,允许拖动。放置源为左侧面板,允许接收放置卡片列表。
拖动源列表
<div className="source-box-outside-style">
{sourceList.map((item) => {
return <SourceBox forbidDrag={setting} record={item}/>;
})}
</div>
具体实现,每一个图表卡片都是一个 SourceBox,item及卡片的实际内容,type 为自定义的一个类型,只有跟放置源的 type 一致,才允许拖拽到放置区内。
const [{ isDragging }, drag] = useDrag({
item: { // 拖动的组件内容
type: record.type,
...record,
},
canDrag: canEdit,
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
<div ref={drag} style={containerStyle} role="SourceBox" />
放置面板
<TargetBox
dragList={storageList}
layout={layout}
width={width}
cards={cards}
currentCards={currentCards}
setting={setting}
initCards={this.initCards}
match={this.props.match}
originCurrentCards={this.originCurrentCards}
onChangeState={this.handleChangeState}
onDropAddCard={this.handleAddCard}
onRemoveCard={this.handleRemoveCard}
/>
放置区只接收卡片列表拖拽过来的卡片,当接收到卡片之后,将其放入网格布局组件列表内,即实现网格布局展示,但需要将卡片放置的 px 坐标转换成网格坐标。具体实现如下:
1、放置区内置 react-grid-layout 组件,用于实现网格布局。
<div ref={drop} className="target-box-style" role="TargetBox">
<GridLayoutPanel onChangeState={onChangeState} onRemoveCard={onRemoveCard} {...props} />
</div>
GridLayoutPanel 组件内部实现
<ReactGridLayout
{...reactGridLayoutProps}
style={layoutContainerStyle}
layout={layout}
className={styles.gridLayoutContainer}
isDraggable={setting}
isResizable={setting}
cols={12}
rowHeight={1}
margin={[1, 1]}
onLayoutChange={this.onLayoutChange}
width={1200} // 默认面板宽度占1200px
>
{this.renderCard()}
</ReactGridLayout>
- isDraggable: 是否允许拖动,编辑状态置为true,默认为false;
- isResizable: 是否可以缩放,与允许拖动设置相同;
- layout: 卡片渲染的布局配置列表,必须与绘制的卡片子组建一一对应;
- cols: 面板横向的栅格列数;
- rowHeight: 栅格每一行所占的高度;
- margin: 数组,表示每个卡片之间的间隔;
- onLayoutChange: 当卡片拖动或缩放时的监听事件;
绘制卡片列表:
/**
* 绘制卡片
* @returns
*/
renderCard = () => {
const { setting = false, cards = [] } = this.props;
return cards.map((item) => {
if (setting) {
return (
<div key={item.name}>
{item.component}
<div className={styles.dragCard} />
{!this.isInitCard(item.name) && (
<Icon
type="close"
className={styles.closeBtn}
onClick={() => {
this.handleRemoveCard(item.name);
}}
/>
)}
</div>
);
}
return (
<div key={item.name} className={styles.boxShadow}>
{item.component}
</div>
);
});
}
当布局改变时,改变布局 layout 列表,重新绘制卡片列表:
/**
* layout 改变的回调
*/
onLayoutChange(layout) {
const { currentCards } = this.props;
const list = [];
const codeMap = {};
if (layout.length && currentCards.length) {
layout.forEach((item) => {
currentCards.forEach((item2) => {
if (item.i === String(item2.cardId)) {
codeMap[item2.cardId] = {
...item,
name: item2.name,
type: item2.type,
url: item2.url,
code: item2.code,
};
}
});
});
}
const keyList = Object.keys(codeMap);
if (keyList.length) {
keyList.forEach((item) => {
list.push(codeMap[item]);
});
}
this.props.onChangeState({ layout: list });
}
2、使用 useDrop 定义放置区
const [{ canDrop }, drop] = useDrop({
accept: cardTypeList(),
drop(_item, monitor) {
const initPos = monitor.getInitialClientOffset(); // 在列表中的初始位置
const delta = monitor.getDifferenceFromInitialOffset(); // 拖动放置之后的偏移量
const x = Math.round((delta.x + initPos.x) / 16); // 拖拽后坐标
const y = Math.round((delta.y + initPos.y) / 16);
onDrop(_item, { x, y }); // 将接收到的卡片放入栅格布局卡片列表中
return undefined;
},
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
});
accept 内容必须与 SourceBox 中的 Type 一致,doop用于监听卡片掉落到放置源上的事件。
卡片掉落后,需要根据卡片的初始位置(即在右侧卡片列表的位置坐标)、放置之后的偏移量(即拖动的距离),来计算拖拽后的实际坐标,转换成网格布局的栅格坐标。
实际计算需要根据面板的宽度和栅格的列数来进行比例转换,计算当前坐标所占栅格数,并取整。
接收到卡片之后,放入栅格布局列表中:
handleAddCard(card = {}, pos = {}) {
const { layout = [], currentCards = [] } = this.state;
const { cardId } = card;
if (layout.some((l) => l.i === String(cardId))) {
// 已经添加了 不要重复添加
return;
}
const layouts = [
...layout,
{
...card,
...pos,
w: 4, // 新增卡片时默认宽占4格
h: 80, // 新增卡片时默认高占80格
i: String(cardId),
},
];
currentCards.push(card);
this.setState(
{
currentCards,
},
() => {
this.loadCards(layouts);
}
);
}
布局列表更新后,重新绘制卡片,将对应的卡片组件放入渲染列表:
/**
* 将 卡片 加载成 layout
*/
loadCards(layouts = []) {
const layout = [].concat(layouts);
let cards = [];
const style = { width: '100%', height: '100%' }
cards = layout.map((card) => {
return {
name: card.i,
component: (
<div style={style}>
<iframe src={card.url} title={card.name} frameBorder={0} height="100%" width="100%" />
</div>
),
};
});
this.setState({
loading: false,
layout,
cards,
});
}