I. Design Philosophy
React DnD gives you a set of powerful primitives, but it does not contain any readymade components. It's lower level than jQuery UI or interact.js and is focused on getting the drag and drop interaction right, leaving its visual aspects such as axis constraints or snapping to you. For example, React DnD doesn't plan to provide a Sortable component. Instead it makes it easy for you to build your own, with any rendering customizations that you need.
(Excerpted from Non-Goals)
In short, DnD features are decomposed into basic interfaces (things that can be grasped, i.e., the 'carrot'; containers where things can be dropped, i.e., the 'pit'), and internal DnD states are exposed to instances that implement these interfaces. Unlike other libraries that provide endless Draggable Components for common business scenarios, React DnD provides support from a relatively low-level perspective. It is an abstraction and encapsulation of drag-and-drop capabilities, simplifying usage through abstraction and hiding underlying differences through encapsulation.
II. Terms and Concepts
Backend
The HTML5 DnD API has poor compatibility and is not suitable for mobile devices. Therefore, the specific DOM events related to DnD are extracted and treated as a separate layer called the Backend:
Under the hood, all the backends do is translate the DOM events into the internal Redux actions that React DnD can process.
Items and Types
An Item is an abstract understanding of an element/component. The objects of drag-and-drop are not DOM elements or React components, but a specific data model (Item):
An item is a plain JavaScript object describing what's being dragged.
Performing this abstraction is also for decoupling:
Describing the dragged data as a plain object helps you keep the components decoupled and unaware of each other.
The relationship between a Type and an Item is similar to that between a Class and a Class Instance. A Type acts as a type identifier to represent a whole class of Items:
A type is a string (or a symbol) uniquely identifying a whole class of items in your application.
Types serve as the matching basis between the carrot (drag source) and the pit (drop target), equivalent to the group name in classic DnD libraries.
Monitor
A Monitor is a collection of drag-and-drop states, such as whether a drag-and-drop operation is in progress, and if so, which is the carrot and which is the pit:
React DnD exposes this state to your components via a few tiny wrappers over the internal state storage called the monitors.
For example:
monitor.isDragging()
monitor.isOver()
monitor.canDrop()
monitor.getItem()
Internal DnD states are exposed via props injection, similar to Redux's mapStateToProps:
export default DragSource(Types.CARD, cardSource, (connector, monitor) => ({
// You can ask the monitor about the current drag state:
isDragging: monitor.isDragging()
}))(Card);
P.S. In fact, React DnD is implemented based on Redux, as seen in the core implementation section below.
Connector
A Connector is used to establish the connection between the DOM abstraction (React) and the specific DOM elements required by the DnD Backend:
The connectors let you assign one of the predefined roles (a drag source, a drag preview, or a drop target) to the DOM nodes
The usage is quite interesting:
render() {
const { highlighted, hovered, connectDropTarget } = this.props;
// 1. Declare the DOM element corresponding to the DnD Role
return connectDropTarget(
<div className={classSet({
'Cell': true,
'Cell--highlighted': highlighted,
'Cell--hovered': hovered
})}>
{this.props.children}
</div>
);
}
// 2. Take the connect method from the connector and inject it into props
export default DragSource(Types.CARD, cardSource, (connector, monitor) => ({
// Call this function inside render()
// to let React DnD handle the drag events:
connectDropTarget: connector.dropTarget()
}))(Card);
The part establishing the connection, connectDropTarget(<div/>), looks quite elegant. I suspect the actual effect is equivalent to:
render() {
const { connectToRole } = this.props;
return <div ref={(node) => connectToRole(node)}></div>
}
And I guessed right:
Internally it works by attaching a callback ref to the React element you gave it.
Drag Source and Drop Target
These two were mentioned above and can be called DnD Roles, representing the roles played within DnD. Besides the drag source and drop target, there is also one called drag preview, which can generally be viewed as a drag source in another state.
DnD Roles are the basic abstract units in React DnD:
They really tie the types, the items, the side effects, and the collecting functions together with your components.
They are a collection of descriptions and actions related to that role, including Types, DnD Event Handlers (e.g., a drop target usually needs to handle hover, drop, etc., events), and so on.
III. Core Implementation
./packages
├── dnd-core
├── react-dnd
├── react-dnd-html5-backend
└── react-dnd-test-backend
The corresponding logical structure looks like this:
API (Connects to React)
react-dnd defines Context, provides Provider, Container factory, and other high-level APIs
-------
Core Abstraction (Defines interfaces)
dnd-core defines Actions and Reducers, connecting the upper and lower layers
-------
Backends (Connects to native, encapsulates DnD features, implements interfaces)
react-dnd-xxx-backend connects to specific environments, passing native DnD states up by Dispatching Actions
This can be viewed as a logical decomposition based on Redux. The middle layer, Core, holds the DnD state; the bottom layer, Backends, is responsible for implementing the agreed-upon interfaces to act as the data source for the Core; and the top-level API retrieves state from the Core and passes it to the business layer.
IV. Basic Usage
1. Specify DragDropContext
Declare DragDropContext for the App root component, for example:
import { DragDropContext } from 'react-dnd';
import HTML5Backend from 'react-dnd-html5-backend';
class App extends Component {}
export default DragDropContext(HTML5Backend)(App);
2. Add DragSource
The DragSource higher-order component takes three parameters (type, spec, and collect()), for example:
export const ItemTypes = {
KNIGHT: 'knight'
};
const knightSpec = {
beginDrag(props) {
// Define the Item structure, read via monitor.getItem()
return {
pieceId: props.id
};
}
};
function collect(connector, monitor) {
return {
connectDragSource: connector.dragSource(),
isDragging: monitor.isDragging()
}
}
Finally, connect it with the Component/Container (just like Redux's connect()):
export default DragSource(ItemTypes.KNIGHT, knightSpec, collect)(Knight);
The component renders the corresponding UI using the injected DnD state, for example:
render() {
const { connectDragSource, isDragging } = this.props;
return connectDragSource(
<div style={{
opacity: isDragging ? 0.5 : 1,
cursor: 'move'
}} />
);
}
The effect of the object being dragged away (becoming semi-transparent) is achieved very naturally, without seeing complex DnD processing logic (which is encapsulated within the React DnD Backend, exposing only the DnD state needed for business).
3. Add DropTarget
Similarly, three parameters are required (type, spec, and collect()):
const dropSpec = {
canDrop(props) {
return canMoveKnight(props.x, props.y);
},
drop(props, monitor) {
const { id } = monitor.getItem();
moveKnight(id, props.x, props.y);
}
};
function collect(connector, monitor) {
return {
connectDropTarget: connector.dropTarget(),
isOver: monitor.isOver(),
canDrop: monitor.canDrop()
};
}
Finally, connect them together:
export default DropTarget(ItemTypes.KNIGHT, dropSpec, collect)(BoardSquare);
The component uses these injected DnD states to display the corresponding UI, for example:
render() {
const { connectDropTarget, isOver, canDrop } = this.props;
return connectDropTarget(
<div>
{isOver && !canDrop && this.renderOverlay('red')}
{!isOver && canDrop && this.renderOverlay('yellow')}
{isOver && canDrop && this.renderOverlay('green')}
</div>
);
}
The effect of the 'pit' changing color according to the validity of the drag operation is also achieved, and it looks equally natural.
4. Customizing DragPreview
By default, browser DnD creates a drag preview based on the dragged element (usually looking like a semi-transparent screenshot). If customization is needed, it is similar to creating a DragSource:
function collect(connector, monitor) {
return {
connectDragSource: connector.dragSource(),
connectDragPreview: connector.dragPreview()
}
}
Customize the DragPreview through the injected connectDragPreview(). The interface signature is consistent with connectDragSource(), both being dragPreview() => (elementOrNode, options?). For example, a common drag handle effect can be implemented like this:
render() {
const { connectDragSource, connectDragPreview } = this.props;
return connectDragPreview(
<div
style={{
position: 'relative',
width: 100,
height: 100,
backgroundColor: '#eee'
}}
>
Card Content
{connectDragSource(
<div
style={{
position: 'absolute',
top: 0,
left: '100%'
}}
>
<HANDLE>
</div>
)}
</div>
);
}
Additionally, an Image object can be used as a DragPreview (not supported in IE):
componentDidMount() {
const img = new Image();
img.src = 'http://mysite.com/image.jpg';
img.onload = () => this.props.connectDragPreview(img);
}
V. Online Demo
GitHub Repository: ayqy/example-react-dnd-nested
Online Demo: https://ayqy.github.io/dnd/demo/react-dnd/index.html
References
- React DnD: Another picturesque documentation that is hard to stop reading, just like the Redux documentation.
- react-dnd/react-dnd
No comments yet. Be the first to share your thoughts.