I've been playing around with dragging and dropping stuff in web browsers for a while. ViewCrafter relies on the Drag and Drop API, since it enables me to pass data easily to drop targets in different windows. I'll probably do a blog post about that at some point.
This blog post is about being able to move an element by dragging it around on a touch screen. Unfortunately, the Drag and Drop API isn't supported all that well on touch devices, and so I've had to dig a bit into the Touch API to provide an experience for the user that works on touch and on traditional desktop browsers.
If you want to see an application of this ability, take a look at my Tower of Hanoi game.
Building up a solution
To get this working, we need a simple layout:
<html>
<div id="container">
<div id="left-parent">
<div id="movable-element"></div>
</div>
<div id="right-parent"></div>
</div>
</html>
Okay, this is a bit bland (and empty), so we'll put in a bit of styling to get a visible layout.
* {
box-sizing: border-box;
}
#container {
display: flex;
}
#container > div {
border: 1px solid gray;
padding: 1em;
height: 10em;
width: 50%;
}
#movable-element {
border: 1px solid green;
background-color: #00ff0033;
height: 100%;
width: 100%;
}
Our objective is to enable the user to move the green element from the left parent to the right, and back again - while updating the document.
How to pick up and make a move
We want the same interaction for the user whether they're using a mouse or using a touch device. So, we're going to programme both functionalities in tandem. This is helped by the fact that there are analogous events between both APIs:
-
touchstart
is equivalent tomousedown
-
touchend
is equivalent tomouseup
-
touchmove
is equivalent tomousemove
There's a couple of caveats. Touch has an additional touchcancel
event which is triggered when the browsers decides something should interrupt the touch behaviour. Also, the touch events carry additional information because you can have multiple touchpoints, whereas the Mouse API only allows for a single mouse pointer.
All that consider, our first step is to allow users to 'pick up' the element. This is done by listening for mousedown
and touchstart
events on the movable element.
<div id="movable-element" onmousedown="pickup(event)" ontouchstart="pickup(event)"></div>
let moving = null;
function pickup(event) {
moving = event.target;
}
Nothing much will happen yet, since we also need to track our mouse/finger movements and move the element to match.
To do this we need to change the element's position to fixed, and also listen out for changes in the mouse/finger position, using mousemove
and touchmove
.
<div id="movable-element"
onmousedown="pickup(event)"
ontouchstart="pickup(event)"
onmousemove="move(event)"
ontouchmove="move(event)"
></div>
let moving = null;
function pickup(event) {
moving = event.target;
moving.style.position = 'fixed';
}
function move(event) {
if (moving) {
// track movement
}
}
Now when we click on the element:
Oh dear - what just happened?
The moving element uses relative height to fill the space available in its parent. When we change its positioning to fixed, the element attempts to fill the whole page, hence the blowout. This is easily fixed, though:
function pickup(event) {
moving = event.target;
moving.style.height = moving.clientHeight;
moving.style.width = moving.clientWidth;
moving.style.position = 'fixed';
}
Let's get moving
The tricky bit here is that mousemove
and touchmove
pass slightly different information in the event. This is because touchmove
allows for multiple touchpoints to move around the screen (a feature that would allow us to do things like pinch-zoom and rotate, if we so wished).
function move(event) {
if (moving) {
if (event.clientX) {
// mousemove
moving.style.left = event.clientX - moving.clientWidth/2;
moving.style.top = event.clientY - moving.clientHeight/2;
} else {
// touchmove - assuming a single touchpoint
moving.style.left = event.changedTouches[0].clientX - moving.clientWidth/2;
moving.style.top = event.changedTouches[0].clientY - moving.clientHeight/2;
}
}
}
We use clientX
and clientY
here to account for the page being scrolled. The element is being positioned relative to the window's left and top edges, so we want to know where our mouse/finger is relative to the window's top-left corner.
Now we have our element tracking our mouse/finger movements, but there a couple more problems now:
- The element sticks to the mouse pointer when we let go of the button.
- The element just sits wherever we left it when we lift up our finger.
Let it go!
What we need to do now is react to the user letting go of the element (mouseup
and touchend
):
<div id="movable-element"
onmousedown="pickup(event)"
ontouchstart="pickup(event)"
onmousemove="move(event)"
ontouchmove="move(event)"
onmouseup="drop(event)"
ontouchend="drop(event)"
></div>
function drop(event) {
if (moving) {
// reset our element
moving.style.left = '';
moving.style.top = '';
moving.style.height = '';
moving.style.width = '';
moving.style.position = '';
moving = null;
}
}
Drop it like it's hot
The final piece of the puzzle is getting the element to actually move when we drop it where we want it to go.
So, we need to know where we've dropped it.
The problem is, because we've made our element move everywhere underneath our pointer/finger, the event's target information is just going to give us the element we're moving, and not any information about where we're trying to drop it.
To overcome this, we can set the z-index of our element so that it appears behind the elements we're moving between. Unfortunately, this hides the element and prevents the event listeners for moving and releasing the element from firing, so we have to make a few changes to where we place them.
<html onmouseup="drop(event)" ontouchend="drop(event)">
<div id="container" onmousemove="move(event)" ontouchmove="move(event)">
<div id="left-parent" onmouseup="drop(event)" ontouchend="drop(event)">
<div id="movable-element" onmousedown="pickup(event)" ontouchstart="pickup(event)"></div>
</div>
<div id="right-parent" onmouseup="drop(event)" ontouchend="drop(event)"></div>
</div>
</html>
function pickup(event) {
moving = event.target;
moving.style.height = moving.clientHeight;
moving.style.width = moving.clientWidth;
moving.style.position = 'fixed';
moving.style.zIndex = '-10';
}
function drop(event) {
if (moving) {
// reset our element
moving.style.left = '';
moving.style.top = '';
moving.style.height = '';
moving.style.width = '';
moving.style.position = '';
moving.style.zIndex = '';
moving = null;
}
}
Putting the move listeners on the container constrains the movement to within that part of the page (if you want to be able to move everywhere, you can put the listeners on the <html>
element instead).
We put the mouseup
and touchend
listeners on the <html>
element so that it doesn't matter where we let go of the mouse or lift up our finger, the element will return to its original location (unless a different element's event listener prevents that). Finally, we put a mouseup
and touchend
listener on each target area (including the original parent for when we want to move back).
Now we're ready to move our element from one part of the document to another.
function drop(event) {
if (moving) {
if (event.currentTarget.tagName !== 'HTML') {
event.currentTarget.appendChild(moving);
}
// reset our element
moving.style.left = '';
moving.style.top = '';
moving.style.height = '';
moving.style.width = '';
moving.style.position = '';
moving.style.zIndex = '';
moving = null;
}
}
event.currentTarget
tells us which element the event triggered on. appendChild
moves the element from it's original parent to the new one. At least, it works on desktops. We have to do something else to get it to work on touch screens.
Touchy touch screens
For some reason, on touch devices, event.currentTarget
gives us the parent of the element we're moving - not the parent we're trying to move to. I don't understand the variation in behaviour here, because touch and mouse have been pretty consistent so far.
Luckily, there is native javascript function that tells us what element is under a specific point on the page - elementFromPoint.
function drop(event) {
if (moving) {
if (event.currentTarget.tagName !== 'HTML') {
let target = null;
if (event.clientX) {
target = document.elementFromPoint(event.clientX, event.clientY);
} else {
target = document.elementFromPoint(event.changedTouches[0].clientX, event.changedTouches[0].clientY);
}
target.appendChild(moving);
}
// reset our element
moving.style.left = '';
moving.style.top = '';
moving.style.height = '';
moving.style.width = '';
moving.style.position = '';
moving.style.zIndex = '';
moving = null;
}
}
That's all
So, there we go, we can now move an element from one parent to another by dragging it with a finger.
About the only problem with this solution is that setting a negative z-index on the moving element means it could get obscured by other elements that are not transparent as we move it around. There is an experimental extension to elementFromPoint
- elementsFromPoint - but it hasn't been fully implemented by all browsers yet. There's also the issue of identifying which of the many elements under that point we want.
Top comments (1)
Thanks for this post, it helped a lot. One thing I tried as an alternative is, rather than setting the z-index lower, to put the dragged element into the backgound, I set it higher, to bring it to the foreground, similar to the desktop drag and drop experience. Then, immediately before calling document.elementFromPoint to find the drop target, I unset the z-index, which seemed to work - the drop target is returned.