1. 程式人生 > >The Future of Drag and Drop APIs

The Future of Drag and Drop APIs

The Future of Drag and Drop APIs

Front-end development matures. We used to keep state in global variables and DOM. Then we wired our apps with message buses and used fine-grained events to translate model changes into DOM updates. Now is the dawn of the next-generation frameworks that embrace top-down data flow,

treat DOM as a remote rendering engine and build composite UI from functions that map state to lightweight element trees.

We are still very much figuring things out. We can generally agree that immutability is good, but cursors, channels and observables show that there is no single way to wire the insides of your app. We are looking for the

basic primitives to express animation and we’re getting there, but can we make this work with always-async batched rendering? Mixins break composition, but have we learned to write good higher order components yet? We’re still figuring out how to fetch data!

This explosion of techniques means you’ll often have to pave your own way through uncharted waters

and learn from your own mistakes. The fun part is that by doing so, you might solve somebody else’s problems too.

The State of Drag and Drop

There are plenty of great drag and drop libraries. You might not even need one — just use HTML5 drag and drop if you only target desktop devices and can live with a quirky API. Why bother writing another drag and drop library?

Before I started working on React DnD, I researched every drag and drop library on the first several pages of Google Search results. Here’s why dozens of otherwise great drag and drop libraries did not fit my use case:

  • They are usually written in the “jQuery plugin” mindset and mutate the DOM directly. They implement dragging by slapping position: absolute onto your element and managing it or cloning the dragged node and putting into an absolute layer inside <body>. This leaves your components incapable of controlling their DOM and directly contradicts the React way of rendering.
  • They often assume that dragging is “moving 2D objects on a surface in four directions”. It usually is, but you hit hard limits with this approach. Such libraries may assume that the objects you want to be draggable have absolute positions on some sort of shared canvas, or that the same item is represented by the same DOM element till the end of time. This doesn’t work for React if you’re implementing a Kanban board, as I will demonstrate later.
  • They often assume that, when drag state changes, you want to toggle a CSS class. With React, state changes aren’t limited to class toggling. A change in state could result in a completely different child tree. This should be just as easy as toggling a class.
  • They rarely provide good ways to “scope” drag and drop interactions by item types (e.g. Trello card and lists are different things). If you have several types of drag sources and drop targets, it is daunting to specify which of them are compatible under what circumstances.
  • They rarely provide a good way to pass data from the drag sources to the drop targets and back. Surprisingly, this is the one part that HTML5 disaster of an API got better than the others.
  • They fail on edge cases. React’s power will likely make you experience those edge cases soon. Will a draggable node receive a dragend or equivalent event if it is removed from DOM while being dragged? Depends on the browser and the library, but generally, it won’t. In React, it’s OK to return null from the render method, depending on the current state of the component. Drag and drop libraries have no idea how to handle this because they tie drag sources and drop targets to specific DOM nodes.

HTML5 Drag and Drop API Disaster

If I can’t use a library, maybe I don’t need one. After all, React is expressive enough that ready-to-go widgets aren’t very popular. Most of the time, if you need something relatively complex, it’s easier to implement it yourself than to depend on something that is not exactly what you need.

There are some amazing tutorialson implementing drag and drop using just the HTML5 API in the React way:

If you have not used the HTML5 drag and drop API from React and have considered trying it, go ahead and read these tutorials. You’ll learn a lot! Still, the vanilla HTML5 drag and drop API didn’t work for me for a number of reasons:

  • It is not (and won’t be) supported on mobile. Bummer. For the Stampsy post editor, we’re OK with desktop-only for the time being, but we need a transition plan for when post editor goes mobile that doesn’t involve rewriting half of the components. The drag and drop implementation needs to be abstracted away from the components. They shouldn’t rely on the native event parameter, event propagation, or really any HTML5 drag and drop API specifics that can’t be replicated on mobile.
  • It has other restrictions, most notably very limited drag preview customization. By default, the browser will just screenshot the dragged element, which is very handy. But when it comes to customizing it, you hit a wall. This article describes the frustration very well. It turns out the custom drag preview has to be in the document and currently visible on screen in order to be used as a drag preview. The browser takes a static screenshot of it, so there is no way to change its appearance while dragging. There are another annoyances, too. It’s hard to correctly align the edges of the drag preview to the original element so it doesn’t jump when you start dragging it. You can use an image as a drag preview, but browsers will freak out (and some will crash) if the image has not loaded yet, so you need to track that. Depending on the browser and devicePixelRatio of your display, browsers will draw the drag preview at different scales. And of course IE11 doesn’t even support setting a custom drag preview. So much for customization.
  • On the other hand, I don’t want to throw away the HTML5 API and just handle mousemove like many libraries do. I want to support file drop targets and the default browser-drawn drag previews until the user wants more customization. Both are only possible with the HTML5 drag and drop API.
  • It’s way too quirky and not usable out of the box if you care about user experience. The HTML5 drag and drop API is broken by default, so you need to remember all the different circumstances under which to call preventDefault. An item dropped outside browser window might not trigger a dragend event. Removing the source DOM node while dragging has the same effect. Dropped files can blow away your page unless you call preventDefault in every handler. WebKit will animate an item to the incorrect position if it has moved since being dragged. Ad nauseum.

All of this can be fixed by a good wrapper. You just don’t want to leak that mess to your components.

Introducing React DnD

By the time I learned all this, I was convinced I needed to write a library that would satisfy the following requirements:

  • Use the HTML5 API by default but do not expose it. Hide inconsistencies, quirks, and implementation details. This way we can later implement a mobile version using touch events.
  • Do not touch the DOM from the library. It should be up to the user to ask whether the drop zone is active in its render method, and toggle CSS classes or render different trees.
  • Define source and drop target logic separately from the DOM. One component may have several sources or targets, and it’s fine. Many components may reuse the source and target logic as well.
  • Steal the idea of type matching and data passing from the HTML5 drag and drop. There are good ideas buried inside bad APIs, you just need to look for them. The HTML5 drag and drop has a concept of matching drag sources and drop targets by string types. Drag sources must specify the type that represents the item being dragged, so that drop targets can say if they know how to handle that type. Drag sources can also specify a JavaScript object describing whatever is being dragged, so that drop targets can handle the drop event without knowing anything about the original drag source.

Thus React DnD and its current API were born. You can check out the examples and their source code to get a taste of what it’s like today. People enjoy using it!

I Made Mistakes

Having implemented a complex drag-and-drop post editor with React DnD, I’m confident that its fundamental bets were right. Abstracting away the bad API was a good idea. Decoupling drag source and drop target logic from DOM was good, too. Thanks to React, embracing top-down rendering made custom drag preview components possible even with the underlying HTML5 API. So we clearly solved some problems in the right way.

However I’m guilty of several mistakes and omissions designing the API and the underlying architecture. I hadn’t considered several use cases that kept popping up as issues. Finally, a month ago, with the help of Nathan Hutchison, things started to come together.

Now I have a much better idea of where I want to take React DnD, and I want you to be part of this journey — by thinking about these ideas, spreading them, and suggesting your own.

Here are a few things we’re fixing in the next version of React DnD:

It won’t rely on mixins

Currently, to define drag sources and drop targets in your component, you need to slap a mixin on it. This is unfortunate since React 0.13 doesn’t encourage mixins anymore. Mixins break composition and make components harder to extract and move around. Instead of mixins, we’re going to borrow and/or combine approaches suggested by Andrew Clark in Why FluxComponent is better than Flux Mixin and by Sebastian Markbåge in Higher-order Components.

The current version of React DnD asks you to define the configureDragDrop method and lets you call getDropState and getDragState from render. Let’s consider a Card component analogous to the cards in Trello UI. They turn into gray placeholders while being dragged. Here’s how a Card might look in the current version of React DnD:

const dragSource = {
beginDrag(component) {
return {
item: {
id: component.props.id
}
};
}
};
export default React.createClass({
mixins: [DragDropMixin],
  propTypes: {
id: PropTypes.string.isRequired,
title: PropTypes.string.isRequired
},
  statics: {
configureDragDrop(registerType) {
// Register drag source with the mixin:
registerType(ItemTypes.CARD, { dragSource });
}
},
  render() {
const { title } = this.props;
    // Ask the mixin if we're being dragged:
const { isDragging } = this.getDragState(ItemTypes.CARD);
    const classNames = classSet({
'Card': true,
'Card—placeholder': isDragging
});
    return (
<div {...this.dragSourceFor(ItemTypes.CARD)}
className={classNames}>
{title}
</div>
);
}
});

If we get rid of mixins and rely solely on prop passing and composition, we can make Card draggable using higher-order components, and keep its implementation completely agnostic of drag and drop:

const CardSource = {
beginDrag() {
return {
id: this.getProps().id
};
}
}
// The vanilla component now has no dependency on React DnD,
// and receives information about current drag state through props.
class Card extends React.Component {
static propTypes = {
connectDragSource: PropTypes.func.isRequired,
isDragging: PropTypes.bool.isRequired,
id: PropTypes.string.isRequired,
title: PropTypes.string.isRequired
},
  render() {
    // No magic methods! We receive everything from props:
    const { title, isDragging, connectDragSource } = this.props;
const classNames = classSet({
'Card': true,
'Card--placeholder': isDragging
});
    return (
<div ref={connectDragSource}
className={classNames}>
{title}
</div>
);
}
}
// Now use a higher-order component wrapper to inject the props.
// configureDragDrop returns a component that wraps the original
// component, forwards any props to it, and additionally supplies
// user-specified props from the drag state.
export default configureDragDrop(
register =>
register.dragSource(ItemTypes.CARD, CardSource),
  cardSource => ({
connectDragSource: cardSource.connect(),
isDragging: cardSource.isDragging()
})
)(Card);

This is not the final API but it may very well look like it. We will also likely add higher-level DragSource and DropTarget components that save some typing but provide less control and performance.

Its core will be decoupled from React

We are not doing this just to appease the Modularity god, but to better understand and test the underlying primitives. It also enables usage outside React, in other libraries like Ember, Angular, or even Cycle. If you’ve been following Ryan Florence’s work (he co-authored React Router), you might have noticed he tried to do the same with nested-router, although it’s unfinished.

It will support testing out of the box

A nice side effect of extracting core logic that doesn’t depend on React is that it already includes a “mock” TestBackend for testing drag states. In fact, that’s how it runs its own test suite. You will be able to switch to the test environment and make assertions on your application’s logic and rendered output in various drag states.

It will include no-compromise support for nesting drop targets

While it’s already possible to nest drag sources in an arbitrary way (live demo), nesting support for drop targets is not so great. We don’t expose stopPropagation because it’s a bad pattern: children should not dictate to parent drop zones whether they should react to drop or hover. For example, a parent drop target might want to scroll the window when you reach its edges, and the fact that pointer is currently over a child target shouldn’t prevent it from receiving the event. However, a parent target may want to know if some child has already handled the drop.

In the next version, we’re going to provide an API that makes all of these scenarios possible. Each target will be able to learn if it’s being hovered by calling the isOver(handle, shallow=false) method. When shallow is false, it will return true even if the cursor is over some child drop target. When shallow is true, it will only return true if you’re hovering over the target’s own area exclusively. It is up to the target to choose whether it wants to appear highlighted.

Each target will also receive a drop event and will be able to return an optional “drop result” value to communicate back to the drag source. This is an important part of the contract that I initially missed. The drag source specifies data to be dragged, and the drop target replies with some “drop result” object. The drag source then handles the end of the dragging operation and may interpret that result to learn about what happened without knowing any details about drop targets.

How does that play with nesting? Easy: drop fires first on the child and then on each parent, so a parent target can use, ignore, or transform a child’s drop result when specifying its own drop result. This covers all sorts of scenarios you might want to implement with nested drop targets.

It will support mirror drag sources

Let me explain what I mean by this. Suppose you implement a Kanban list app like Trello, where you have several lists with cards and you can drag cards between the lists.

While you drag a card, a gray placeholder appears in the source list. We already saw a simplified implementation of Card component before. Let me recap the render method with the current mixin API:

render() {
const { title } = this.props;
const { isDragging } = this.getDragState(ItemTypes.CARD);
  const classNames = classSet({
'Card': true,
'Card—placeholder': isDragging
});
  return (
<div {...this.dragSourceFor(ItemTypes.CARD)}
className={classNames}>
{title}
</div>
);
}

The interesting part here is getDragState provided by DragDropMixin. It will reach into the state managed by the mixin and determine whether this component started the current drag operation. If it did, render() will return a gray placeholder while the browser moves the card’s screenshot under the pointer. There’s a similar example in the React DnD live demo so you can check it out.

Things start to fall apart when you hover a card over a different list. If you’re familiar with the Trello UI, that’s when we’re supposed to move the card placeholder into the other list (but keep it gray until the user drops). That’s easy to do if you mutate the DOM (just move the node to another list), but if we’re determined to use components and top-down data flow, how is the newly mounted component in the other list supposed to know that it corresponds to the item currently being dragged?

I think this is the biggest API mistake I’ve made with React DnD, and the one I’m most eager to fix in the next version. React DnD succeeded in decoupling drag sources and drop targets from DOM nodes, but it still couples them to component instances.

That’s why DnD Core, the new engine for React DnD, has a centralized drag source and drop handler registration mechanism, instead of registering them locally at the component level, like the current version of React DnD. This makes it possible to add a method called isDragging to the drag source contract.

If drag source defines this method, it is used to determine whether a particular drag source represents the currently dragged item. The default implementation is just checking whether it’s the same drag source that started the operation.

However, you are free to implement your own version — which might, for example, check the component’s props to see if the ID of currently dragged item (which is just a JavaScript object) matches the ID in props. This way the newly mounted Card in the other list will immediately appear as a gray placeholder, even though technically it did not initiate the drag: it merely represents (or “mirrors”) the same drag source as the original Card.

This also solves a different problem: what if a Card “receives” another model via componentWillReceiveProps and technically “becomes” another card? Again, this approach has you covered because our custom isDragging implementation looks into the current props:

const CardSource = {
beginDrag() {
return {
id: this.getProps().id
};
}
  isDragging(context) {
return this.getProps().id === context.getItem().id;
}
}

The mirroring is also already implemented in DnD Core with tests, so we just need to figure out a way to bring all of this to the next version of React DnD.

It will remove the APIs that can be described as React lifecycle hooks

When I wrote the first version of React DnD, I used the jQuery UI API contract as a starting point. This is how, in addition to the over hook, the drop targets got enter and leave hooks.

Adding these hooks wasn’t a very wise decision. They are ambiguous when drop targets are nested. If I drag an item over the parent target and then enter the child target, should parent target receive leave? Or should leave only be called on the parent when I drag out of it? One solution to this would be comparing e.currentTarget with e.target, but this easily leads to event propagation hell.

This is why we’re removing the enter and leave hooks for good. We don’t need them, because if we receive drag state using props, we can configure shallowness when injecting drag state and use componentWillReceiveProps in the component itself:

class DropZone extends React.Component {
componentWillReceiveProps(nextProps) {
const { isOver } = this.props;
const { isOver: nextIsOver } = nextProps;
    if (nextIsOver && !isOver) {
console.log('enter');
} else if (!nextIsOver.isOver && isOver) {
console.log('leave');
}
}
render() {
const { isOver, connectDropTarget } = this.props;
return (
<div ref={connectDropTarget}>
{isOver && 'Currently dragged over!'}
</div>
);
}
}
// target.isOver({ shallow: bool }) lets us know
// whether we are currently dragging over a drop target.
const ShallowDropZone = configureDragDrop(
register =>
register.dropTarget(ItemType.COIN, CoinTarget),
  coinTarget => ({
// ShallowZone will only react to being directly dragged over:
isOver: coinTarget.isOver({ shallow: true }),
connectDropTarget: coinTarget.connect()
})
)(DropZone);

const GreedyDropZone = configureDragDrop(
register =>
register.dropTarget(ItemType.COIN, CoinTarget),
  coinTarget => ({
// GreedyZone will stay in hover state
// when the child drop targets are dragged over:
isOver: coinTarget.isOver,
connectDropTarget: coinTarget.connect()
})
)(DropZone);

Next Steps

I hope you find these ideas and techniques as fascinating as I do. I’m immeasurably grateful to every contributor and every person filing issues about the ways the library does not work for them, forcing me to think hard about the higher-level problems.

In the coming months I plan to work on developing these features and stabilizing the API so that the next version of React DnD is ready for ES6 classes and Kanban-like use cases. This will prepare us for the more crazy features such as an alternative touchmove-based backend and support for dragging multiple items at once.

If your company is interested in the future development of React DnD and DnD Core, consider sponsoring my work. You can reach me at [email protected] to discuss this.

Stay tuned!

Star or watch React DnD on Github

Star or watch DnD Core on Github

Follow Dan Abramov on Twitter

Thanks to Andrew Clark for proofreading this article.