React Drag and Drop with Touch Support
Tweet this postIn a previous post, I explained the wonderful principle of Duck Typing.
If it looks like a duck, swims like a duck, and quacks like a duck, then it probably is a duck.
In this post, I want to show how I applied duck typing to enhance an existing library’s functionality with only minimal changes to that library.
React DnD
React.js seems to be on the lips of every web developer these days, and many new libraries are springing up to solve old problems in the new react-like way. Once such library is the fantastic react-dnd library 3
3. dnd = drag and drop
As the documentation says so eloquently:
React DnD is a set of React higher-order components to help you build complex drag and drop interfaces while keeping your components decoupled.
By default, React DnD uses HTML5 drag and drop events. Support for this is contained in a separate library: react-dnd-html5-backend. The reason for the separate library is so that, if you want, you can use a backend that relies instead on other events from other platforms: for example, touch events on touch screen devices.
With the ubiquity of touch screen devices and their current lack of support for html5 drag and drop events, the ability to add a custom backend that uses touch events is handy. As luck would have it, the good people at Yahoo have written one: react-dnd-touch-backend. Unfortunately, react-dnd-touch-backend doesn’t have good support for HTML5 mouse events (including drag events). It even says so in the documentation:
This is buggy … I highly recommend that you use react-dnd-html5-backend instead for a more performant native HTML5 drag capability
It also doesn’t currently implement a drag layer, meaning the thing you’re dragging is invisible while you’re dragging it.
Even if react-dnd-touch-backend were perfect, we’d still have to choose either one or the other either upfront or during runtime. If we want to use HTML5 drag and drop events we choose react-dnd-html5-backend and if we want our code to run on touch devices we choose react-dnd-touch-backend… Hmm… You can probably see where I’m going with this. I want both.
And I’m not the only the one who’s asking for both touch and HTML5 support: Issue #16 - Touch backend.
What we need is a custom backend that incorporates the functionality of both react-dnd-html5-backend and react-dnd-touch-backend.
react-dnd-html5-backend is the most developed library, so let’s find some way to amend that library to add the touch support we need.
Adding touch support
Generating our own drag and drop events
At the current time, most touch devices don’t support html5 drag and drop events. Distinguishing between a user making a drag operation and a user making a touch gesture (like swiping) is hard, and most browsers on touch devices don’t bother. Since browsers don’t do this for us, we’ll generate our own drag and drop events in response to user touches.
Mapping touch events
We want to detect when a touch event is actually a drag event. Then we can then programmatically generate the necessary drag and drop events.
In html, the draggable attribute specifies that a html element is draggable, so let’s assume that when a touchstart event is triggered on a draggable element the user is beginning a drag operation and we’ll emit a dragstart event.
At this point we know (or at least can assume) we’re in the middle of a drag operation, so any touchmove events can be re-emitted as drag events. Also, as the user’s finger moves around, we should emit dragover events on elements beneath the touch point (ignoring the dragged element itself). When the element the user is dragging over changes we should emit a dragleave event on the old element and a dragenter event on the new element.
When the user lifts their finger the touchend event is triggered, and we need to emit both a dragend event and a drop event on the last element we we’re dragging over.
Here’s a summary those event mappings:
touch event | drag event(s) |
---|---|
touchstart | dragstart |
touchmove |
drag dragenter dragover dragleave |
touchend |
drop dragend |
Triggering our events
Using Javascript, let’s generate a dragstart event from a touchstart event.
document.addEventListener('touchstart', function (touchevent) {
// prevent the touchstart event from having an effect
event.preventDefault();
var target = touchevent.target;
var type = 'dragstart'
var touchDetails = touchEvent.changedTouches[0];
var event = new Event(type, {'bubbles': true});
event.altkey = touchEvent.altkey;
event.button = 0;
event.buttons = 1;
event.cancelBubble = false;
event.clientX = touchDetails.clientX;
event.clientY = touchDetails.clientY;
event.ctrlKey = touchEvent.ctrlKey;
// event.dataTransfer = ???????;
event.layerX = 0;
event.layerY = 0;
event.metaKey = false;
event.movementX = 0;
event.movementY = 0;
event.offsetX = touchDetails.pageX - target.offsetLeft;
event.offsetY = touchDetails.pageY - target.offsetTop;
event.pageX = touchDetails.pageX;
event.pageY = touchDetails.pageY;
event.relatedTarget = touchEvent.relatedTarget;
event.returnValue = touchEvent.returnValue;
event.screenX = touchDetails.screenX;
event.screenY = touchDetails.screenY;
event.shiftKey = touchEvent.shiftKey;
event.sourceCapabilities = touchEvent.sourceCapabilities;
event.view = touchEvent.view;
event.which = 1;
event.x = touchDetails.clientX;
event.y = touchDetails.clientY;
// trigger the dragstart event
target.dispatchEvent(event);
});
The dragstart event that this code generates looks almost like a proper dragstart event generated by a browser but there’s one thing missing that all proper drag and drop events should have: a DataTransfer object.
Mimicking the DataTransfer object
The DataTransfer object is where all the juicy drag and drop information is kept. It is shared by all the events generated by a drag and drop operation.
The official HTML spec tells us what this DataTransfer object should do: DataTransfer interface
Unfortunately there is no DataTransfer constructor function. It takes a bit of work, but, writing a Javascript object that conforms to the spec, we end up with something like the following.
// Simplified possible implementation of a DataTransfer
// object that is compliant with the html spec
{
store: {
mode: "readwrite", // can also be "protected"
},
types: [],
files: [],
// "none", "copy", "link", or "move"
dropEffect: "move",
// "none", "copy", "copyLink", "copyMove",
// "link", "linkMove", "move",
// "all", or "uninitialized"
effectAllowed: "uninitialized",
items: [],
setData: function (format, data) {
if (!this.store) { return; }
if (this.store.mode !== "readwrite") {
return;
}
format = format.toLowerCase();
if (format === "text") {
format = "text/plain";
} else if (format === "url") {
format = "text/uri-list";
}
this.typeTable[format] = data;
this.types = Object.keys(this.typeTable);
},
clearData: function (format) {
var self = this;
if (!this.store) { return; }
if (this.store.mode !== "readwrite") {
return;
}
if (typeof format === "undefined") {
// Clear all formats (except "Files");
this.types
.filter(function (type) {
return type !== "Files";
})
.forEach(function (type) {
return self.clearData(type);
});
return;
}
format = format.toLowerCase();
if (format === "text") {
format = "text/plain";
} else if (format === "url") {
format = "text/uri-list";
}
delete this.typeTable[format];
this.types = Object.keys(this.typeTable);
},
setDragImage: function (element, x, y) {
if (!this.store) { return; }
if (this.store.mode !== "readwrite") {
return;
}
var preview = element.cloneNode(true);
preview.width = element.clientWidth;
preview.height = element.clientHeight;
preview.dragPointOffsetX = -x;
preview.dragPointOffsetY = -y;
this.store.dragPreviewElement = preview;
},
getData: function (format) {
if (this.store.mode === "protected") {
return "";
}
format = format.toLowerCase();
let convertToUrl = false;
if (format === "text") {
format = "text/plain";
} else if (format === "url") {
format = "text/uri-list";
convertToUrl = true;
}
if (!(format in this.typeTable)) {
return "";
}
let result = this.typeTable[format];
if (convertToUrl) {
// set result to the first URL from the list,;
// if any, or the empty string otherwise.
// [RFC2483];
result = parseTextUriList(result)[0] || "";
}
return result;
}
}
With a bit more work, we can create our own constructor function that generates these DataTransfer objects. (For the sake of brevity I won’t show that constructor function here).
Returning to our previous code, we can utilize our new DataTransfer constructor function and amend our code to the following:
var touchDndCustomEvents = {
'dataTransfer': null,
'draggedItem': null,
'lastDraggedOver': null,
'dragOvers': null,
'store': null
};
function simulateEvent(type, touchEvent, dataTransfer, target) {
var touchDetails = touchEvent.changedTouches[0];
var event = new Event(type, {'bubbles': true});
event.altkey = touchEvent.altkey;
event.button = 0;
event.buttons = 1;
event.cancelBubble = false;
event.clientX = touchDetails.clientX;
event.clientY = touchDetails.clientY;
event.ctrlKey = touchEvent.ctrlKey;
event.dataTransfer = dataTransfer;
event.layerX = 0;
event.layerY = 0;
event.metaKey = false;
event.movementX = 0;
event.movementY = 0;
event.offsetX = touchDetails.pageX - target.offsetLeft;
event.offsetY = touchDetails.pageY - target.offsetTop;
event.pageX = touchDetails.pageX;
event.pageY = touchDetails.pageY;
event.relatedTarget = touchEvent.relatedTarget;
event.returnValue = touchEvent.returnValue;
event.screenX = touchDetails.screenX;
event.screenY = touchDetails.screenY;
event.shiftKey = touchEvent.shiftKey;
event.sourceCapabilities = touchEvent.sourceCapabilities;
event.view = touchEvent.view;
event.which = 1;
event.x = touchDetails.clientX;
event.y = touchDetails.clientY;
target.dispatchEvent(event);
}
function handleTouchStart (event) {
var target = event.target;
if (target.hasAttribute("draggable")) {
event.preventDefault();
var x = event.changedTouches[0].clientX;
var y = event.changedTouches[0].clientY;
var store = {};
var dataTransfer = new DataTransfer(store);
// Save the details so we can reuse them
// throughout the drag operation
touchDndCustomEvents.store = store;
touchDndCustomEvents.dataTransfer = dataTransfer;
touchDndCustomEvents.draggedItem = target;
store.mode = 'readwrite';
simulateEvent('dragstart', event, dataTransfer, target);
}
}
document.addEventListener('touchstart', handleTouchStart, true);
Let’s add support for emitting all the other drag and drop events as well.
function handleTouchMove (event) {
if (touchDndCustomEvents.draggedItem) {
event.preventDefault();
var x = event.changedTouches[0].clientX;
var y = event.changedTouches[0].clientY;
var dataTransfer = touchDndCustomEvents.dataTransfer;
var draggedItem = touchDndCustomEvents.draggedItem;
touchDndCustomEvents.store.mode = 'readwrite';
simulateEvent('touchdrag', event, dataTransfer, draggedItem);
var draggedOver = document.elementFromPoint(x, y);
var lastDraggedOver = touchDndCustomEvents.lastDraggedOver;
if (lastDraggedOver !== draggedOver) {
if (lastDraggedOver) {
clearInterval(touchDndCustomEvents.dragOvers);
simulateEvent('dragleave', event, dataTransfer, lastDraggedOver);
}
simulateEvent('dragenter', event, dataTransfer, draggedOver);
touchDndCustomEvents.dragOvers = setInterval(function(){
simulateEvent('dragover', event, dataTransfer, draggedOver);
}, dragOverInterval);
touchDndCustomEvents.lastDraggedOver = draggedOver;
}
}
}
document.addEventListener('touchend', handleTouchEnd, true);
function handleTouchEnd (event) {
if (touchDndCustomEvents.draggedItem) {
event.preventDefault();
var x = event.changedTouches[0].clientX;
var y = event.changedTouches[0].clientY;
var target = document.elementFromPoint(x, y);
var dataTransfer = touchDndCustomEvents.dataTransfer;
// Ensure dragover event generation is terminated
clearInterval(touchDndCustomEvents.dragOvers);
touchDndCustomEvents.store.mode = 'readonly';
simulateEvent('touchdrop', event, dataTransfer, target);
touchDndCustomEvents.store.mode = 'protected';
simulateEvent('touchdragend', event, dataTransfer, target);
touchDndCustomEvents.store = null;
touchDndCustomEvents.dataTransfer = null;
touchDndCustomEvents.lastDraggedOver = null;
touchDndCustomEvents.draggedItem = null;
}
}
document.addEventListener('touchmove', handleTouchMove, true);
Ta Da!… Oh it doesn’t work
Yes, that’s right. Unfortunately, our solution doesn’t actually work in a browser. Try listening to one of our drag events on a touch device and your handler won’t get called.
document.addEventListener('dragstart', function () {
alert('This is never gonna happen.')
});
simulateEvent('dragstart', event, dataTransfer, target);
You see, although browsers allow 4 you to generate custom events with the same names as a native events, browsers still don’t treat them like native events.
4. By “allow” I mean that browsers don’t raise any error.
If you try to programmatically trigger a custom event named click, no click handlers will fire. Why? ‘Cos security. That’s why.
Imagine if web developers could pretend to be users and trigger clicks, touches and key presses etc. They could get up to all sorts of mischief. So browsers don’t allow it. “You’re not a real user-triggered-event,” they say. “So I’m not gonna tell anyone about you”.
This means that programmatically triggering event handlers for a custom event dragstartdummy would be no problem, but triggering event handlers for dragstart cannot be done programmitically. 5.
5. unless you call them directly
Duck typing to the rescue
We can’t programmatically trigger event handlers for proper drag events, so, instead, we’ll trigger our own custom drag events.
- Instead of html5 dragstart we’ll trigger touchdragstart
- Instead of html5 drag we’ll trigger touchdrag
- Instead of html5 dragenter we’ll trigger touchdragenter
- Instead of html5 dragleave we’ll trigger touchdragleave
- Instead of html5 dragover we’ll trigger touchdragover
- Instead of html5 drop we’ll trigger touchdrop
- Instead of html5 dragend we’ll trigger touchdragend
We’ve been careful to make our custom events look exactly like the html5 drag and drop events, and if it looks and behaves like a duck; it’s a duck, so, hopefully, react-dnd will just treat our custom events like real drag and drop events.
First we’ll wrap all the above code into a new npm library (touch-dnd-custom-events).
Next we’ll use this library and get react-dnd-html5-backend to listen to our custom events.
Adding touch support to react-dnd-html5-backend
Thankfully, react-dnd-html5-backend is so nicely written that the only code we need to change is isolated all in one file: src/HTML5Backend.js. 6
6. Note that earlier examples in this post used ES5 syntax. However, react-dnd-html5-backend is written using the latest Javascript syntax, ES2015/ES6, so the following example also uses this newer syntax.
// Within react-dnd-html5-backend
// src/HTML5Backend.js
// ####################################
// ##### Add the following import #####
// ####################################
import setupTouchDNDCustomEvents from 'touch-dnd-custom-events';
// Other imports not shown
export default class HTML5Backend {
constructor(manager) {
// not shown
}
setup() {
if (typeof window === 'undefined') {
return;
}
if (this.constructor.isSetUp) {
throw new Error('Cannot have two HTML5 backends at the same time.');
}
this.constructor.isSetUp = true;
this.addEventListeners(window);
// ####################################
// ##### Add the following line #######
// ####################################
this.addCustomEventListeners(window);
}
teardown() {
if (typeof window === 'undefined') {
return;
}
this.constructor.isSetUp = false;
this.removeEventListeners(window);
// ####################################
// ##### Add the following line #######
// ####################################
this.removeCustomEventListeners(window);
this.clearCurrentDragSourceNode();
}
// ########################################
// ##### Add the following function #######
// ########################################
addCustomEventListeners(target) {
target.addEventListener(
'touchdragstart', this.handleTopDragStart
);
target.addEventListener(
'touchdragstart', this.handleTopDragStartCapture, true
);
target.addEventListener(
'touchdragend', this.handleTopDragEndCapture, true
);
target.addEventListener(
'touchdragenter', this.handleTopDragEnter
);
target.addEventListener(
'touchdragenter', this.handleTopDragEnterCapture, true
);
target.addEventListener(
'touchdragleave', this.handleTopDragLeaveCapture, true
);
target.addEventListener(
'touchdragover', this.handleTopDragOver
);
target.addEventListener(
'touchdragover', this.handleTopDragOverCapture, true
);
target.addEventListener(
'touchdrop', this.handleTopDrop
);
target.addEventListener(
'touchdrop', this.handleTopDropCapture, true
);
}
// ########################################
// ##### Add the following function #######
// ########################################
removeCustomEventListeners(target) {
target.removeEventListener(
'touchdragstart', this.handleTopDragStart
);
target.removeEventListener(
'touchdragstart', this.handleTopDragStartCapture, true
);
target.removeEventListener(
'touchdragend', this.handleTopDragEndCapture, true
);
target.removeEventListener(
'touchdragenter', this.handleTopDragEnter
);
target.removeEventListener(
'touchdragenter', this.handleTopDragEnterCapture, true
);
target.removeEventListener(
'touchdragleave', this.handleTopDragLeaveCapture, true
);
target.removeEventListener(
'touchdragover', this.handleTopDragOver
);
target.removeEventListener(
'touchdragover', this.handleTopDragOverCapture, true
);
target.removeEventListener(
'touchdrop', this.handleTopDrop
);
target.removeEventListener(
'touchdrop', this.handleTopDropCapture, true
);
}
connectDragSource(sourceId, node, options) {
setupTouchDNDCustomEvents();
this.sourceNodes[sourceId] = node;
this.sourceNodeOptions[sourceId] = options;
const handleDragStart = (e) => this.handleDragStart(e, sourceId);
const handleSelectStart = (e) => this.handleSelectStart(e, sourceId);
node.setAttribute('draggable', true);
node.addEventListener('dragstart', handleDragStart);
node.addEventListener('selectstart', handleSelectStart);
// ####################################
// ##### Add the following line #######
// ####################################
node.addEventListener('touchdragstart', handleDragStart);
return () => {
delete this.sourceNodes[sourceId];
delete this.sourceNodeOptions[sourceId];
node.removeEventListener('dragstart', handleDragStart);
node.removeEventListener('selectstart', handleSelectStart);
// ####################################
// ##### Add the following line #######
// ####################################
node.removeEventListener('touchdragstart', handleDragStart);
node.setAttribute('draggable', false);
};
}
connectDropTarget(targetId, node) {
const handleDragEnter = (e) => this.handleDragEnter(e, targetId);
const handleDragOver = (e) => this.handleDragOver(e, targetId);
const handleDrop = (e) => this.handleDrop(e, targetId);
node.addEventListener('dragenter', handleDragEnter);
node.addEventListener('dragover', handleDragOver);
node.addEventListener('drop', handleDrop);
// #######################################
// ##### Add the following 3 lines #######
// #######################################
node.addEventListener('touchdragenter', handleDragEnter);
node.addEventListener('touchdragover', handleDragOver);
node.addEventListener('touchdrop', handleDrop);
return () => {
node.removeEventListener('dragenter', handleDragEnter);
node.removeEventListener('dragover', handleDragOver);
node.removeEventListener('drop', handleDrop);
// #######################################
// ##### Add the following 3 lines #######
// #######################################
node.removeEventListener('touchdragenter', handleDragEnter);
node.removeEventListener('touchdragover', handleDragOver);
node.removeEventListener('touchdrop', handleDrop);
};
}
// The remaining 29 functions in this file
// are unaffected: no changes required
}
That’s it: only 75 lines of code (with generous white spacing) added to just 6 of the 35 functions in this file, and we haven’t even touched the other 9 files in react-dnd-html5-backend (or any of the files in react-dnd itself).
Here’s it working in
Summary
Here’s a quick summary of what we just did:
- We wrote our own library to transform touch events into custom versions of drag events, and we made sure our custom drag events comply with the HTML5 spec (so we can use Duck Typing)
- We added listeners for our custom events to react-dnd-html5-backend, which, applying the principle of Duck Typing, just triggers the exact same event handlers as were already being used for normal browser generated HTML5 drag and drop events.
And, with that relatively small amount of effort, the result is working HTML5 compliant drag and drop on touch screen devices.
Here’s the finished result in all its glory.
I hope this gives you a glimpse into how Duck Typing can be applied pragmatically to a real world problem.
React DnD with Touch Support - Where are we?
There is a pull request (#50) on github to add touch functionality (using the approach described in this post) to the react-dnd-html5-backend library, but some further work is required before this can be merged.
I have also created another library, react-dnd-html5-with-touch-backend, which is a simple wrapper around react-dnd-html5-backend, but with added touch support. Use react-dnd-html5-with-touch-backend in the same way you’d use react-dnd-html5-backend.
Disclaimer
While this approach is sound (and certainly works for my particular use case), the custom events generated by touch-dnd-custom-events do not yet have all the functionality necessary to substitute seemlessly for real drag and drop events (or at least not as far as React DnD is concerned), so there are still some use cases for React DnD for which the exact solution as outlined above is insufficient. I hope to find time to address this and provide a more complete solution in the future.