I am creating a custom draggable directive in AngularJS. It's a combination of jQuery events and vanilla javascript. I am trying to make this as generic and re-useable as possible, and it also has to be touch friendly.
TL;DR
I can't click the button on my draggable directive in touch environments.
Steps to reproduce:
Longer Explanation
The directive optionally makes the entire element it's placed on draggable, unless an element with the class "drag-handle" is placed, in which case that is used as the drag handle for the element. I normally use this with bootstrap panels, as it's an easy example.
The directive works great on desktop, however on touch devices, if there are any clickable items on a drag handle, the drag handler overrides the click event and it's never called.
Example HTML would be:
<div class="panel panel-default" app-draggable>
<div class="panel-heading drag-handle"> <!-- Drag Handle -->
<div class="panel-title">
Example Title
<button onclick="alert('clicked')" class="btn btn-xs btn-primary pull-right" type="button">Click</button>
</div>
</div>
<div class="panel-body">Example body</div>
</div>
So on desktops, you can both drag the panel, and click the button to get the alert. However, when I emulate iPad 3/4 on Chrome (or pull it up on a real iPad) the click is never fired.
My directive is below. It sets the container to be absolute (unless the container is already fixed, in which case it will compensate and still make it draggable.
/*
* @summary
* Directive that makes an element draggable.
* @description
* This directive should be used in conjunction with specifying a drag handle
* on the element. If not, then entire element will be draggable.
* @example
* <div class='myDiv' app-draggable>
* <div class='drag-handle'>This will be the drag handle</div>
* <div>This will be dragged</div>
* </div>
*/
angular.module("app")
.directive('appDraggable', appDraggable);
function appDraggable() {
var directive = {
restrict: 'A',
link: link
};
function link(scope, element) {
var startX = 0, startY = 0, x = 0, y = 0;
var startTop;
var startLeft;
var dragHandle = element[0].querySelector(".drag-handle");
var dragHandleElement;
/*
* If there is a dragHandle specified, add the touch events to it.
* Otherwise, make the entire element draggable.
*/
if (dragHandle) {
dragHandleElement = angular.element(dragHandle);
addTouchHandlers(dragHandle);
} else {
dragHandleElement = element;
addTouchHandlers(element[0]);
}
var position = element.css('position');
if (position !== "absolute") {
if (position === "fixed") {
// If fixed, get the start offset relative to the document.
startTop = element.offset().top;
startLeft = element.offset().left;
/*
* Explicitly set the height and width of the element to prevent
* overrides by preset values.
*/
var height = parseInt(element.height(), 10);
var width = parseInt(element.width(), 10);
element.css({
height: height,
width: width
});
} else {
// If it's not fixed, it needs to be absolute.
element.css({
position: 'absolute',
});
// And positioned originally relative to the parent.
startTop = element.position().top;
startLeft = element.position().left;
}
}
/*
* @function
* @description
* Add event handlers to the drag handle to capture events.
*/
dragHandleElement.on('mousedown', function (event) {
/*
* Prevent default dragging of selected content
*/
event.preventDefault();
startX = event.pageX - x;
startY = event.pageY - y;
dragHandleElement.on('mousemove', mousemove);
dragHandleElement.on('mouseup', mouseup);
});
function mousemove(event) {
y = event.pageY - startY;
x = event.pageX - startX;
var finalTop = y + startTop;
var finalLeft = x + startLeft;
element.css({
top: finalTop + 'px',
left: finalLeft + 'px'
});
}
function mouseup() {
dragHandleElement.off('mousemove', mousemove);
dragHandleElement.off('mouseup', mouseup);
}
function touchHandler(event) {
var touch = event.changedTouches[0];
if (event.target !== dragHandleElement) {
//////////////// HACK ///////////////////////////
//event.target.click(); // Hack as a work around.
}
var simulatedEvent = document.createEvent("MouseEvent");
simulatedEvent.initMouseEvent({
touchstart: "mousedown",
touchmove: "mousemove",
touchend: "mouseup"
}[event.type], true, true, window, 1,
touch.screenX, touch.screenY,
touch.clientX, touch.clientY, false,
false, false, false, 0, null);
touch.target.dispatchEvent(simulatedEvent);
event.preventDefault();
}
function addTouchHandlers(element) {
element.addEventListener("touchstart", touchHandler, true);
element.addEventListener("touchmove", touchHandler, true);
element.addEventListener("touchend", touchHandler, true);
element.addEventListener("touchcancel", touchHandler, true);
}
}
return directive;
}
You'll notice that there's a hack in the directive above:
if (event.target !== dragHandleElement) {
//////////////// HACK ///////////////////////////
//event.target.click(); // Hack as a work around.
}
If I uncomment this, it works on touch devices because this checks to see if the touch targe is the dragHandle and if it's not, manually clicks the target. This works, but seems nasty to me and I'd really like a better solution. It does not return false or stopPropagation because the target is not always the dragHandle directly, but it still needs to drag.
I don't know why this doesn't work, because it doesn't manually stop the propagation of the touch event, as it uses event.preventDefault instead of event.stopPropagation, but I'm sure I'm missing something.
You can reproduce here.
Also, any other recommendations on how to improve the above code to be more platform device agnostic or more robust are welcome!
Thoughts?
Thanks!
Found the problem.
My touchHandler
function above always transmits a "mousedown" event on touch, even if it was more accurately a "click" event it should be transmitting. Since all my event handlers were looking for a "click" event, they were ignoring the "mousedown" event that was being transmitted.
I changed up my touchHandler
function to the below and it works like a charm.
var mouseMoved = false;
function touchHandler(event) {
// Declare the default mouse event.
var mouseEvent = "mousedown";
// Create the event to transmit.
var simulatedEvent = document.createEvent("MouseEvent");
switch (event.type) {
case "touchstart":
mouseEvent = "mousedown";
break;
case "touchmove":
/*
* If this has been hit, then it's a move and a mouseup, not a click
* will be transmitted.
*/
mouseMoved = true;
mouseEvent = "mousemove";
break;
case "touchend":
/*
* Check to see if a touchmove event has been fired. If it has
* it means this have been a move and not a click, if not
* transmit a mouse click event.
*/
if (!mouseMoved) {
mouseEvent = "click";
} else {
mouseEvent = "mouseup";
}
// Initialize the mouseMove flag back to false.
mouseMoved = false;
break;
}
var touch = event.changedTouches[0];
/*
* Build the simulated mouse event to fire on touch events.
*/
simulatedEvent.initMouseEvent(mouseEvent, true, true, window, 1,
touch.screenX, touch.screenY,
touch.clientX, touch.clientY, false,
false, false, false, 0, null);
/*
* Transmit the simulated event to the target. This, in combination
* with the case statement above, ensures that click events are still
* transmitted and bubbled up the chain.
*/
touch.target.dispatchEvent(simulatedEvent);
/*
* Prevent default dragging of element.
*/
event.preventDefault();
}
This implementation looks for a touchmove
event in between the touchstart
and touchend
. If there is one, then it sets a flag and transmits a click
event instead of a mousedown
event.
It could also be used in conjunction with a timer so that even if the mouse was moved a small amount it would transmit a click event, but for my purposes this works wonderfully.