前言
拖拽功能在我们的开发过程中经常会遇到,应用拖拽功能的场景也是多种多样的,今天我们就来一步步的实现一个简单、通用的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
属性可设置为:
move
copy
link
none
在不同的拖拽应用场景,通过给 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
逐层传递。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
的内容。
DndManager Component
接收 dropMode
和 onDragEnd
作为 props
,dropMode
支持外部设置拖拽的模式,onDragEnd
方法当拖拽的状态为 'drop' 时,会执行。
每个 Context 对象都会返回一个 Provider React 组件,它允许消费组件订阅 context 的变化
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 保持独立,元素既可以具备单一特性,也可以两者都具备。