前言
拖拽功能在我们的开发过程中经常会遇到,应用拖拽功能的场景也是多种多样的,今天我们就来一步步的实现一个简单、通用的React 拖拽组件,解决元素拖拽的需求。
应用场景
首先,先看看我们实现的拖拽组件的最终的应用效果吧:
拖拽元素调整位置
应用我们封装的拖拽组件可以实现元素之间通过拖拽调整元素的位置、顺序:
拖拽元素复制到另一个区域
应用我们封装的拖拽组件还可以实现将元素从一个区域复制到另一个区域:
当然,除了以上展示的两种应用场景外,我们还可以利用这个拖拽组件满足各种各样的拖拽需求。
那么,接下来,我们将重点介绍如何实现这样一个简单、通用的 React 拖拽组件。
拖拽 API
HTML 为我们提供了许多与实现元素拖拽(Drag and Drop)相关的 API,这是实现拖拽组件的基石。
实现一个拖拽组件必须让我们的元素具备两个特性,即 drag 和 drop。
drag 相关
HTML 定义的与 drag 相关事件有:
ondrag当拖拽元素时触发。ondragend当拖拽操作结束时触发。ondragenter当拖拽元素到一个可释放目标时触发。ondragexit当元素变得不再是拖拽操作的选中目标时触发。ondragleave当拖拽元素离开一个可释放目标时触发。ondragover当元素被拖到一个可释放目标上时触发(每100毫秒触发一次)。ondragstart当用户开始拖拽一个元素时触发。
让一个元素具有 Drag 特性,只需要给相应的元素添加 draggable 属性,便可以响应元素上与 drag 相关事件。
drop 相关
而 HTML 定义的与 drop 相关的事件只有一个:
ondrop当元素在可释放目标上被释放时触发。 让一个元素具备drop特征,需要在该元素上添加对ondrop事件和ondragover事件的监听。
dataTransfer 对象
除此之外,与 drag 和 drop 息息相关的还有一个 dataTransfer 对象,这个对象存在于所有的 drag 和 drop 相关的事件对象 DragEvent 中。
设置 dataTransfer 的 dropeffct,可以控制当拖动元素到可 drop 元素上时鼠标呈现的样式。
dropeffct 属性可设置为:
movecopylinknone
在不同的拖拽应用场景,通过给 dropeffct 设置合适的值,可以呈现更好的视觉效果。
相关 API 详情可参考官方文档。
在我们设计的 React 组件中,主要会使用 ondragstart 、ondragend、ondragover、 ondragleave 和 ondrop 事件。
拖拽组件设计
我们设计的 react 拖拽组件,主要由四个部分组成:draggableConnect、droppableConnect、DndComponent 以及 DndManager 。
DndManager:管理器,管理拖拽组件的数据、状态和交互。DndComponent:可拖拽元素的容器,是拖拽组件的主体,支持拖拽组件相关的配置。draggableConnect: 为React元素添加drag相关的属性和事件。droppableConnect: 为React元素添加drop相关的属性和事件。
DndManager
DndManager 由 DndManagerContext 和 DndManager组件构成。
在拖拽过程中所有 draggable 元素可定义为 source,所有的 droppable 元素可定义为 target。
DndManagerContext
在创建 DndManagerContext 过程中,我们使用到了 React Context 相关的知识。Context 能为一个组件树提供一个共享的“全局”数据,组件树内的组件都能消费这些“全局”数据,而无需通过 props 逐层传递。(参考 React 官网 Context 的介绍)
DndManagerContext 应该包含对所有 source 和 target 集合的管理,以及对拖拽过程的状态和当前拖拽的元素的管理。所以在DndManagerContext中需要定义 sourceMap 和 targetMap 以及相关的增删方法,定义 result 记录拖拽的过程和修改记录的方法 changeResult 。
export enum EDragResultStatus {
DRAG = 'DRAG',
DROP = 'DROP',
CANCEL = 'CANCEL',
}
export type sourceId = string | null;
export type targetId = string | null;
export type dropMode = DataTransfer['dropEffect'];
export type source = any;
export type target = any;
export type sourceMap = Record<string, source>;
export type targetMap = Record<string, target>;
export interface IResult {
sourceId: sourceId;
targetId: targetId;
status: EDragResultStatus;
hoverId: targetId;
}
export interface IDndManager {
dropMode: dropMode;
sourceMap: sourceMap;
targetMap: targetMap;
result?: IResult;
changeResult?: (result: Partial<IResult>) => void;
addSource: (sourceId: sourceId, source: source) => void;
removeSource: (sourceId: sourceId) => void;
addTarget: (targetId: targetId, target: target) => void;
removeTarget: (targetId: targetId) => void;
}
首先,我们通过 React.createContext 创建一个 DndManagerContext,并设置初始值:defaultContext。
import React from 'react';
import { IDndManager } from './type';
// Initial value of DndManagerContext
const defaultContext: IDndManager = {
dropMode: 'move',
sourceMap: {},
targetMap: {},
addSource: console.log,
removeSource: console.log,
addTarget: console.log,
removeTarget: console.log,
};
// Context lets us pass a value deep into the component tree
// without explicitly threading it through every component.
export const DndManagerContext = React.createContext<IDndManager>(defaultContext);
DndManager Component
接着,我们需要创建 DndManager 组件,我们需要在 DndManager 组件中为 DndManagerContext 赋予真正有效的值和方法,并通过 Context.Provider 使得组件树内的任何组件都可以订阅到 DndManagerContext 的内容。
每个 Context 对象都会返回一个 Provider React 组件,它允许消费组件订阅 context 的变化
DndManager Component 接收 dropMode 和 onDragEnd 作为 props,dropMode支持外部设置拖拽的模式,onDragEnd方法当拖拽的状态为 'drop' 时,会执行。
interface Props {
children: React.ReactNode;
onDragEnd: (result: IResult) => void;
dropMode?: dropMode;
}
interface State {
dropMode: dropMode;
sourceMap: sourceMap;
targetMap: targetMap;
result: IResult;
}
export default class DndManager extends Component<Props, State> {
// value of Context
state: State = {
dropMode: this.props.dropMode || 'move',
sourceMap: {},
targetMap: {},
result: {
targetId: null,
sourceId: null,
status: null,
hoverId: null,
},
};
// change the result of dnd
public changeResult(result: Partial<IResult>) {
const { onDragEnd } = this.props;
const newResult = { ...this.state.result, ...result };
// when the status is 'drop', trigger 'onDragEnd' event
if (result.status && result.status === DragResultStatusEnum.DROP) {
onDragEnd(newResult);
}
this.setState({ ...this.state, result: newResult });
}
// add a source that draggable elemnt
public addSource(sourceId: sourceId, source: source) {
...
}
// add a target that droppable elemnt
public addTarget(targetId: targetId, target: target) {
...
}
// remove a source
public removeSource(sourceId: sourceId) {
...
}
// remove a target
public removeTarget(targetId: targetId) {
...
}
// get the value of dropMode from props and set to the state
componentWillReceiveProps(nextProps: Props) {
const { dropMode } = this.state;
if (dropMode !== nextProps.dropMode) {
this.setState({
dropMode: nextProps.dropMode,
});
}
}
render() {
const { children } = this.props;
return (
// Every Context object comes with a Provider React component
// that allows consuming components to subscribe to context changes
<DndManagerContext.Provider
value={{
...this.state,
addSource: this.addSource.bind(this),
addTarget: this.addTarget.bind(this),
removeSource: this.removeSource.bind(this),
removeTarget: this.removeTarget.bind(this),
changeResult: this.changeResult.bind(this),
}}
>
{children}
</DndManagerContext.Provider>
);
}
}
DndComponet
DndComponet 是拖拽组件的主体部分,它主要具备三个职责:
- 通过
React.useContext()订阅DndManagerContext。 - 向
DndManager上报source和target的变化。 - 判断
sourceId和targetId,执行draggableConnect和droppableConnect。
export interface IDragDropProps {
children: React.ReactElement
sourceId?: string;
targetId?: string;
}
const DndComponent = ({ children, sourceId, targetId }: IDragDropProps): React.ReactElement => {
// get ref
const dndContainerRef = React.useRef<HTMLElement>();
// subscribe context
const dndManager = React.useContext(DndManagerContext);
React.useEffect(() => {
// check targetId and sourceId and then execute dndManager.add
if (targetId) {
dndManager.addTarget(targetId, dndContainerRef);
}
if (sourceId) {
dndManager.addSource(sourceId, dndContainerRef);
}
}, [children, sourceId, targetId]);
// when component unmount, execute dndManager.remove
React.useEffect(() => {
return () => {
if (sourceId) dndManager.removeSource(sourceId);
if (targetId) dndManager.removeTarget(targetId);
};
}, []);
// dropabbleConnect or draggableConnect with children
return cloneElement(
dropabbleConnect(
draggableConnect(
children,
sourceId,
dndManager
),
targetId,
dndManager
),
{ ref: dndContainerRef }
);
};
export default DndComponent;
draggableConnect
draggableConnect 使用 React.cloneElement() 将目标元素克隆,并为其添加 drag 相关的属性,返回具备 drag 特性的 React 元素。
cloneElement以element元素为样板克隆并返回新的React元素。返回元素的props是将新的props与原始元素的props浅层合并后的结果。新的子元素将取代现有的子元素,而来自原始元素的key和ref将被保留。
export function draggableConnect(
element: React.ReactElement,
sourceId: sourceId,
dndManager: IDndManager
): React.ReactElement {
...
...
return React.cloneElement(element, {
draggable: true,
onDragStart: dragStartHandler,
onDragEnd: dragEndHandler,
});
}
添加的属性包括draggable、onDragStart和onDragEnd。
onDragStart事件是拖拽过程的起点,在dragStartHandler中,设置拖拽模式 e.dataTransfer.dropEffect,将 dndManager 的拖拽状态设置为 ‘drag',并且将当前元素的 id 设置为整个拖拽过程的 sourceId。
const dragStartHandler = (e: React.DragEvent<HTMLDivElement>) => {
if (dndManager.dropMode) e.dataTransfer.dropEffect = dndManager.dropMode;
// init status of dnd and set sourceId
dndManager?.changeResult({
status: DragResultStatusEnum.DRAG,
sourceId: sourceId,
hoverId: null,
targetId: null,
});
};
onDragEnd事件是整个拖拽过程最后触发的事件,在dragEndHandler中,判断 dndManager.result 的 sourceId 和 targetId,如果都存在则意味元素正常 drop,设置拖拽状态为 ‘drop‘,否则说明拖拽事件取消,设置状态为 ‘cancel’。
const dragEndHandler = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
// Set different status values by judging the sourceId and targetId
if (dndManager.result.sourceId && dndManager.result.targetId) {
dndManager?.changeResult({
status: DragResultStatusEnum.DROP,
hoverId: null,
});
} else {
dndManager?.changeResult({
status: DragResultStatusEnum.CANCEL,
hoverId: null,
sourceId: null,
targetId: null,
});
}
};
droppableConnect
droppableConnect 使用 React.cloneElement() 将目标元素克隆,并为其添加 drop 相关的属性onDrop、onDragOver、onDragLeave,返回具备 drop 特性的 React 元素。
export function dropabbleConnect(
element: React.ReactElement,
targetId: targetId,
dndManager: IDndManager
): React.ReactElement {
...
...
// clone element
return React.cloneElement(element, {
onDrop: dropHandler,
onDragOver: dragOverHandler,
onDragLeave: dragLeaveHandler,
});
}
onDrop在元素完成 drop 时触发,在dropHandler中将 dndManager 当前拖拽过程的 targetId 设置为当前元素的 Id。
const dropHandler = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
if (dndManager.dropMode) e.dataTransfer.dropEffect = dndManager.dropMode;
// set targetId
dndManager.changeResult({
targetId: targetId,
});
};
onDragOver 和 onDragLeave 在拖拽元素进入和离开可 drop 元素时触发,在事件监听函数中主要完成 dndManager 的 hoverId 的设置 。
const dragOverHandler = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
if (dndManager.dropMode) e.dataTransfer.dropEffect = dndManager.dropMode;
// set hoverId
dndManager.changeResult({
hoverId: targetId,
});
};
const dragLeaveHandler = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
// clear hoverId
dndManager.changeResult({
hoverId: null,
});
};
以上便完成了拖拽组件四个主要部分的创建,并且通过 DndManager 组件 和 DndComponent 组件,实现拖拽功能:
<DndManager
onDragEnd={(v) => {
console.log(v);
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
width: '300px',
marginTop: '200px',
}}
>
<DndComponent sourceId="source_1">
<button>Source</button>
</DndComponent>
<DndComponent targetId="target_1">
<button>Target</button>
</DndComponent>
</div>
</DndManager>
不过,到这里并不意味着结束,还有一些case需要处理。
Enhancements
当拖拽元素进入可 drop 的 traget 元素时,我们希望可以给 target 添加特定的样式,例如改变元素背景,如下图:
实现这个的效果,我们需要判断当前元素是否在拖拽过程中 hover。在 dndManager.result 中有关于 hover 元素的信息,即hoverId,通过判断 hoverId 和当前元素的 id 可以判断元素是否 hover, 即 isDragOver 。
{ isDragOver: dndManager.result.hoverId === targetId }
除此之外,还需要能够在 DndComponent 组件内的子元素获取到 isDragOver 的值。
如何在组件的 children 中获取到组件内的内容呢?
实现这一点,需要让 DndComponent 组件支持函数类型的 children,而 isDragOver 可以作为函数的 argument。
interface IDragDropProps {
children: React.ReactElement | (({ isDragOver }: { isDragOver: boolean }) => React.ReactElement);
...
}
const DndComponent = ({ children, sourceId, targetId }: IDragDropProps): React.ReactElement => {
...
...
return cloneElement(
dropabbleConnect(
draggableConnect(
// 判断 children 的类型
typeof children === 'function'
// 将 isDragOver 传递给 children
? children({ isDragOver: dndManager.result.hoverId === targetId })
: children,
sourceId,
dndManager
),
targetId,
dndManager
),
{ ref: dndContainerRef }
);
};
这样我们就可以在 target 元素中获取 isDragOver 属性,并实现特效。
<DndComponent targetId="target_1">
{({ isDragOver }) => (
<button style={{ background: isDragOver ? '#e6f7ff' : '#fff' }}>Target</button>
)}
</DndComponent>
边界 case 处理
在我们的组件中还发现一个问题,target 元素响应了非同一 manager 的 source 元素的 drop 事件。
当拖拽组件分属不同的 manager 时,往往意味着它们有不同的拖拽行为,因此不同 manager 管理的拖拽组件之间不应该产生交互。
解决这个问题需要在 target 元素的事件中添加对 sourceId 的判定。
const dropHandler = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
const { sourceId } = dndManager.result;
// check sourceId
if (sourceId && dndManager.sourceMap[sourceId]) {
if (dndManager.dropMode) e.dataTransfer.dropEffect = dndManager.dropMode;
dndManager.changeResult({
targetId: targetId,
});
}
};
const dragOverHandler = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
const { sourceId } = dndManager.result;
// check sourceId
if (sourceId && dndManager.sourceMap[sourceId]) {
if (dndManager.dropMode) e.dataTransfer.dropEffect = dndManager.dropMode;
dndManager.changeResult({
hoverId: targetId,
});
} else {
e.dataTransfer.dropEffect = 'none';
}
};
const dragLeaveHandler = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
const { sourceId } = dndManager.result;
// check sourceId
if (sourceId && dndManager.sourceMap[sourceId]) {
dndManager.changeResult({
hoverId: null,
});
}
};
解决后的拖拽效果:
总结
在本篇文章中 我们结合拖拽 API 完成了一个简单通用的拖拽组件设计,在这个过程中也总结了几个关键的点:
- 保持组件的简洁,只对外暴露 manager 和 dnd 组件就能够满足多样的需求。
- 采用 manager 统一管理多个组件的拖拽行为和状态。
- 利用 Context 实现 manager,这样的方式具备两个优点:
- 将 manager 作为组件树的全局变量
- 不用关心拖拽组件在整个组件树的位置,意味着可以随意的组合内部的元素
- 采用 cloneElement,可以在不影响元素原有属性的前提下,添加额外属性,扩展元素。
- draggableConnect 和 droppableConnect 保持独立,元素既可以具备单一特性,也可以两者都具备。