Wednesday, October 15, 2014

Creating A Resortable Drag & Drop List With Angular JS.

I recently ran into a requirement that required a user to be able to resort a given list elements via drag and drop.  As soon as I heard about the requirement I shuddered a bit.  I had the vague memory of trying drag and drop once and it not being as intuitive as it should have been.

I started with a quick Google search to see what was out there and while there were some good starting points, there wasn't anything that was exactly what I wanted.  Pretty much all the examples I found were dragging from area A to area B like this.  Nothing showed resorting in the same list like I had hoped.  What was even a bit more dismaying was when I searched specifically for angular.js implementations of drag and drop I found that it didn't even come with directives for those events.  There were some directives that others implemented for drag and drop, but I decided I should figure it out for myself instead of trying to fiddle with other people's code.

So to get started I decided to break my angular code into 3 directives.  One for making an element, draggable, one for making an element droppable, and one for making the entire list sortable.

Three sir.

Draggable directive.

To make something draggable we want to create a directive that can be applied to any element that we want to make draggable.  So I restricted the directive to "A" for attribute, and added a link function that attached event handlers to the dragstart and dragend events.  In the dragstart event I needed to do a couple of things.  First set the data that is going to be transferred (this could be anything but I set mine to an attribute on the element ), tell the browser what type of transfer is happening, and finally I want to add a class to the element I'm dragging so I can style the element more appropriately.  In the dragend event handler I just want to remove said class.

The end result ends up looking like this:

directive('draggable', function(){
    return {
        restrict: 'A',
        link: function (scope, element, attributes) {
            var el = element[0];
            el.draggable = true;
            
            el.addEventListener('dragstart', handleDragStart, false);
            el.addEventListener('dragend', handleDragEnd, false);
            function handleDragStart(e){
              this.classList.add('dragging');
              
              e.dataTransfer.effectAllowed = 'move';
              e.dataTransfer.setData('data', attributes.dragData);                
                
            }
            
            function handleDragEnd(e) {
              // this/e.target is the source node.
              this.classList.remove('dragging');
            
                           
            }
        }
    }
})

What is important to note here is whatever effectAllowed property you set, will dictate certain styles like the mouse cursor, and it limits where it can be dragged to other elements that have that same effect allowed.

Likewise, for the setData attribute, you can name that whatever you like.  It is a message for you to handle later.  So if you actually have multiple sets of data to drag and drop, you can limit what accepts it by having different "types".

Droppable directive.

The droppable directive is going to be similar.  Once again I am going to limit it to be an attribute on the element and to mostly adding events to the element.  The first events that I am going to handle are dragenter and dragleave.  All I am going to end up doing with these events is adding and removing a class to the element we are dragging over so we can add an additional style to it.  Next I am going to handle the dragover event where I am going to set the dropEffect of the element to match what is in the effectAllowed above.  Finally I am going to handle the drop event in which I will grab the data specified in the draggable directive above, and call a function on the scope.

Here is what it looks like:

.directive('droppable', function(){
    return {
        restrict:"A",
        link: function(scope, element, attributes) {
            var el = element[0];
            el.addEventListener('dragenter', handleDragEnter, false);
            el.addEventListener('dragover', handleDragOver, false);
            el.addEventListener('dragleave', handleDragLeave, false);
            el.addEventListener('drop', handleDrop, false);
            
            function handleDragOver(e) {
              if (e.preventDefault) {
                e.preventDefault(); // Necessary. Allows us to drop.
              }
              e.dataTransfer.dropEffect = 'move';  
            
              return false;
            }
            
            function handleDragEnter(e) {
                // this / e.target is the current hover target.
                this.classList.add('dragover');
            }
            function handleDrop(e){
                // Stops some browsers from redirecting.
                if (e.stopPropagation) e.stopPropagation();
                var data =e.dataTransfer.getData('data');
                
                if ('function' == typeof scope.ondrop) {      
                    scope.ondrop(data, attributes.dropData , e);
                }
                this.classList.remove('dragover');  
            }
            function handleDragLeave(e) {
                if (e.preventDefault) e.preventDefault();
                this.classList.remove('dragover');  
               
            }
        }
    };
})


Reorder Directive

The reorder directive is tied to an Array object and attaches the draggable and droppable directives onto each element in the array.  I decided to make the reorder directive based on an element ("E") and enabled transclude on it allow you to style each list element yourself.  The rest of the directive creates 1 <li> element for each object in the array and implements the drop handler needed in the draggable directive.  In the drop handler I had to add an $apply call to notify the UI that changes had been made to the array and that it should be updated.

Here is the directive:
directive('reorder', function () {
    return {
        restrict: 'E',
        transclude: true, 
        scope: {
            list: '='
        },
        template: '
  • ', link: function (scope, element) { scope.ondrop = function resort(dragIndex, dropIndex, event){ if (dragIndex == dropIndex){ return; } scope.$apply(function(){ dragIndex = Number(dragIndex); // grab element to move var elementToMove = scope.list[dragIndex]; // remove the element scope.list.splice(dragIndex, 1); // insert the element scope.list.splice(Number(dropIndex), 0, elementToMove); }); }; } }; });


    Here is the end result:
    Cheers!




    No comments:

    Post a Comment