Native drag and drop in AngularJS
I’m going to assume in this post that you know a bit about AngularJS, but if not I strongly recommend you watch the videos over at egghead & have a read through the official documentation.
There are already AngularJS drag & drop directives out there, but these require jQuery-UI, this post will show you how to use JavaScript’s native drag & drop instead.
You’ll probably want to readup on native drag & drop if you’re not familiar with it so here’s some excellent articles & documentation:
Setting up the app
First up we need to set up an AngularJS application & html:
var app = angular.module('dragDrop', []);
<body ng-app="dragDrop">
<div class="bin"></div>
<div class="item" id="item1"></div>
</body>
As you can see the html is just an item & a bin, our aim is to be able to drop the item in the bin.
The draggable directive
We’ll now create an AngularJS directive that will make an item draggable.
app.directive('draggable', function() {
return function(scope, element) {
}
});
This is the actual directive & because we’re returning a function AngularJS will, by default, use a restrict
value of “A”, meaning our directive will be set using an attribute on an element, like so:
<div class="item" id="item1" draggable></div>
We’ll now add the code to the directive. This will mark the element as draggable & add the two event listeners for dragging items, dragstart
& dragend
.
app.directive('draggable', function() {
return function(scope, element) {
// this gives us the native JS object
var el = element[0];
el.draggable = true;
el.addEventListener(
'dragstart',
function(e) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('Text', this.id);
this.classList.add('drag');
return false;
},
false
);
el.addEventListener(
'dragend',
function(e) {
this.classList.remove('drag');
return false;
},
false
);
}
});
The two event listeners in this case simply add & remove a class called drag
to the item (which can have any styling you’d like, e.g. opacity: 0.5
) & set up the dataTransfer. Setting the effectAllowed
dataTransfer property means that we’re specifying the item being dragged is to be moved when it’s dropped, this will be important later on. Putting the id of the element into the dataTransfer data will mean that we can get the element when a drop
event occurs.
The droppable directive
Next we’ll add the droppable directive so that we can specify elements that we can drop items in.
app.directive('droppable', function() {
return {
scope: {},
link: function(scope, element) {
// again we need the native object
var el = element[0];
}
}
});
This is slightly different to the draggable directive because we want to isolate the scope so we can’t just return the link
function. Our reason for wanting to isolate the scope is so that we can call a function in a controller later on.
We’ll also update our html to make our bin droppable.
<div class="bin" droppable></div>
We now need to add four events to the droppable element in our droppable directive link
function:
dragover
dragenter
dragleave
drop
Firstly, dragover
:
el.addEventListener(
'dragover',
function(e) {
e.dataTransfer.dropEffect = 'move';
// allows us to drop
if (e.preventDefault) e.preventDefault();
this.classList.add('over');
return false;
},
false
);
As you can see we’re setting the dropEffect
property of dataTransfer to “move”, the same as we set effectAllowed
in the dragstart
event. If these values didn’t match the browser wouldn’t let us drop the item into the bin. Adding an over
class to the element on dragover
will allow us to set a style that shows the user that the item can be dropped here.
The next two events, dragenter
& dragleave
are small & simple:
el.addEventListener(
'dragenter',
function(e) {
this.classList.add('over');
return false;
},
false
);
el.addEventListener(
'dragleave',
function(e) {
this.classList.remove('over');
return false;
},
false
);
These events mean that when our draggable item enters & leaves a droppable element the over
class is added or removed.
The final event is the most important one, drop
.
el.addEventListener(
'drop',
function(e) {
// Stops some browsers from redirecting.
if (e.stopPropagation) e.stopPropagation();
this.classList.remove('over');
var item = document.getElementById(e.dataTransfer.getData('Text'));
this.appendChild(item);
return false;
},
false
);
This event will remove the over
class & get the item element from the id that we put into the dataTransfer in the dragstart
event & append it to the droppable element. This gives us the ability to drag our .item
element & drop it into our .bin
element. You can see this in action in this pen.
Talking to a controller
It’s likely that once you’ve dropped an element you’ll want to do something more, e.g. make a call to your REST API to update your database. We can do this by setting an attribute on our droppable element with a controller function that we want to call on drop.
<div class="bin" droppable drop="handleDrop()"></div>
Then we’ll add a controller to our html & application.
<body ng-app="dragDrop" ng-controller="DragDropCtrl">
<div class="bin" droppable drop="handleDrop()"></div>
<div class="item" id="item1" draggable></div>
</body>
app.controller('DragDropCtrl', function($scope) {
$scope.handleDrop = function() {
alert('Item has been dropped');
}
});
Now, in our droppable directive we’ll need to isolate the scope of drop
so that it’s in the parent scope & also call the drop function.
app.directive('droppable', function() {
return {
scope: {
drop: '&' // parent
},
link: function(scope, element) {
...
el.addEventListener(
'drop',
function(e) {
// Stops some browsers from redirecting.
if (e.stopPropagation) e.stopPropagation();
this.classList.remove('over');
var item = document.getElementById(e.dataTransfer.getData('Text'));
this.appendChild(item);
// call the drop passed drop function
scope.$apply('drop()');
return false;
},
false
);
}
}
});
Note the use of $apply()
, if we simply did scope.drop()
an error would occur if no drop attribute was present, using $apply()
prevents this.
Now when we drop our .item
element in the .bin
element we’ll get an alert “Item has been dropped”.
You can see the full code for this post in this pen.
It’s also worth noting that by passing in the function to be called on drop you can have different functions for each different droppable area, here’s a pen with an example.
UPDATE: 29/10/2013—ng-repeat
This post has proven a lot more popular than I thought it would & a lot of the comments have quite rightly pointed out that this solution doesn’t play well with ng-repeat due to scope issues. This update is my solution to solving this & also to allow the IDs of both the item & the bin to be passed to the controller function on drop.
HTML amends
The changes to the HTML are:
- addition of attributes to match the ng-repeat variable - this will mean the ng-repeat item will still be in scope on each iteration
- dynamic ID generation
- removing the brackets on the drop attribute - this allows us to pass parameters to it later on
- the ng-repeat attribute
Here’s the new HTML:
<div class="bin" droppable drop="handleDrop" ng-repeat="bin in [1, 2, 3]" bin="bin" id="bin{{ bin }}">{{ bin }}</div>
<div class="item" id="item{{ item }}" ng-repeat="item in [1, 2, 3]" draggable item="item">{{ item }}</div>
This will create 3 items & 3 bins.
Droppable directive changes
We need to update the scope object so that it includes the bin
attribute we added to the mark up:
...
scope: {
drop: '&',
bin: '=' // bi-directional scope
},
link: ...
This is what allows the ng-repeat item to stay in scope.
We also need to change the code in the drop
event to the following:
var binId = this.id;
var item = document.getElementById(e.dataTransfer.getData('Text'));
this.appendChild(item);
// call the passed drop function
scope.$apply(function(scope) {
var fn = scope.drop();
if ('undefined' !== typeof fn) {
fn(item.id, binId);
}
});
This is quite a change from just $scope.apply('drop()');
, but really all we’re doing is calling the function separately so that we can pass two parameters, the bin and item ids, to it.
There’s a pen for this solution right here & as you can see, when an item is dropped into the bin an alert will display saying which item was dropped where.
This may not be the best way to do this, so if anyone’s got a better way, please leave a comment!