lundi 4 février 2013

Drag & Drop avec AngularJS

Le drag & drop est revenu à la mode avec l'arrivée des tablettes et des smartphones. Un temps boudé par les développeurs web, il est de bon ton d'offrir de tels mécanismes dans nos applications web. A l'image de Trello et de ses fameuses colonnes, nous allons voir comment rendre des éléments html draggable et droppable.

Pour ceux qui veulent voir, directement, le code complet et une démo, un jsFiddle est disponible tout en bas.

AngularJS est très fort lorsqu'il s'agit d'afficher un modèle. Mais qu'en est-il de mettre à jour le modèle en fonction d'actions dans la vue ? Et bien une fois de plus AngularJS nous surprend en nous permettant de rajouter des comportements au HTML. Pour ce faire il suffit de créer des directives.

Nous allons donc créer deux directives, une pour le drag et une autre pour le drop. La première directive se chargera de rendre l'élément HTML draggable et gérera les événements dragStart et dragEnd. Tandis que la directive du drop permettra à l'élément HTML d'afficher un feedback lors du survol et appellera un callback lors du drop.

Ecouter un événement du DOM


Pour pouvoir placer un écouteur sur un événement d'un élément HTML, AngularJS nous offre la fonction bind (tout droit issu de la petite partie de JQuery embarquée) :


link: function(scope, element, attrs) {
    element.bind('dragenter', function(evt) {
        ...
    });
}


Un peu de code : les directives


Nous pouvons donc écire la directive drag :


app.directive("drag", ["$rootScope", function($rootScope) {
  
  function dragStart(evt, element, dragStyle) {
    element.addClass(dragStyle);
    evt.dataTransfer.setData("id", evt.target.id);
    evt.dataTransfer.effectAllowed = 'move';
  };
  function dragEnd(evt, element, dragStyle) {
    element.removeClass(dragStyle);
  };
  
  return {
    restrict: 'A',
    link: function(scope, element, attrs)  {
      attrs.$set('draggable', 'true');
      scope.dragData = scope[attrs["drag"]];
      scope.dragStyle = attrs["dragstyle"];
      element.bind('dragstart', function(evt) {
        $rootScope.draggedElement = scope.dragData;
        dragStart(evt, element, scope.dragStyle);
      });
      element.bind('dragend', function(evt) {
        dragEnd(evt, element, scope.dragStyle);
      });
    }
  }
}]);


Cette directive rajoute l'attribut "draggable" sur l'élément HTML et écoute les événements du DOM qui nous intéressent. Nous pouvons également remarquer que les attributs de la directive sont récupérés grâce au paramètre attrs (attributs). Il est préférable, dans ce cas, de ne pas créer de scope isolé afin de pouvoir utiliser la directive de drag avec d'autres directives (ng-repeat par exemple).

De plus, le $rootScope est utilisé pour stocker l'objet (du modèle) qui sera transféré lors du drop. En effet, comme nous découplons la directive du drag et la directive du drop, lors du drop nous voudrons connaitre l'objet du drag. Pour ce faire le plus simple est d'utiliser le scope racine de tous les scopes. Mais vous pouvez très bien le stocker dans le scope parent si les directives sont sur des éléments HTML frères.




La directive pour le drop :


app.directive("drop", ['$rootScope', function($rootScope) {
  
  function dragEnter(evt, element, dropStyle) {
    evt.preventDefault();
    element.addClass(dropStyle);
  };
  function dragLeave(evt, element, dropStyle) {
    element.removeClass(dropStyle);
  };
  function dragOver(evt) {
    evt.preventDefault();
  };
  function drop(evt, element, dropStyle) {
    evt.preventDefault();
    element.removeClass(dropStyle);
  };
  
  return {
    restrict: 'A',
    link: function(scope, element, attrs)  {
      scope.dropData = scope[attrs["drop"]];
      scope.dropStyle = attrs["dropstyle"];
      element.bind('dragenter', function(evt) {
        dragEnter(evt, element, scope.dropStyle);
      });
      element.bind('dragleave', function(evt) {
        dragLeave(evt, element, scope.dropStyle);
      });
      element.bind('dragover', dragOver);
      element.bind('drop', function(evt) {
        drop(evt, element, scope.dropStyle);
        $rootScope.$broadcast('dropEvent', $rootScope.draggedElement, scope.dropData);
      });
    }
  }
}]);


Outre la gestion des styles qui est triviale, nous remarquons le broadcast de l’événement dropEvent, qui permet de prévenir les écouteurs qu'un drop a eu lieu. Comme pour la directive de drag, il est plus simple d'émettre l'événement à partir du rootScope, mais il pourrait très bien être émit avec un scope parent.

Si vous n'êtes pas à l'aise avec les scopes et les événements, n'hésitez pas à lire (ou à relire) l'article : AngularJS : Scopes et évènements.

Utilisons nos directives


Il ne nous reste plus qu'à créer le HTML :

    <div ng-repeat="c in columns" class="column"
      drag="c" dragStyle="columnDrag"
      drop="c" dropStyle="columnDrop">
      {{c.title}}
    </div>



Et notre contrôleur :

app.controller('ColumnController', ["$scope", "$rootScope", 
  function($scope, $rootScope) {

    $scope.columns = [{title:"1"}, {title:"2"}, {title:"3"}, {title:"4"}];
    
    $rootScope.$on('dropEvent', function(evt, dragged, dropped) {
        var i, oldIndex1, oldIndex2;
        for(i=0; i<$scope.columns.length; i++) {
            var c = $scope.columns[i];
            if(dragged.title === c.title) {
                oldIndex1 = i;
            }
            if(dropped.title === c.title) {
                oldIndex2 = i;
            }
        }
        var temp = $scope.columns[oldIndex1];
        $scope.columns[oldIndex1] = $scope.columns[oldIndex2];
        $scope.columns[oldIndex2] = temp;
        $scope.$apply();
    });
    
}]);


Dans ce contrôleur, nous écoutons l'événement dropEvent afin d'inverser les colonnes dans le modèle. Ainsi, lors du $scope.apply(), angularJs rafraîchira la vue, et le drag&drop sera effectif.

Le jsFiddle complet.

En vrai ça donne quoi ?


Vous pouvez voir cette technique appliquée sur le projet skimbo. La seule différence est qu'il y a un écouteur supplémentaire, il s'agit du service de communication avec le serveur. Ainsi le nouvel ordre des colonnes est envoyé au serveur pour être sauvegardé. Si ça vous intéresse vous trouverez tout le code du projet sur github.

J'espère que cet article vous sera utile. N'hésitez pas à commenter, améliorer et débattre dans les commentaires ou sur le forum !

8 commentaires:

  1. Cet article m'a été très utils et instructif.
    Par contre je me suis confronté à un problème en utilisant la directive drag ( et drop) de cet manière:

    <li ng-repeat="item in items" drap="$index" drop="$index" dragStyle="drag" dropStyle="drop">

    En effet l'attribut drag n'ai jamais recalculé, car fixé lors du link.
    Chez moi "evt.dataTransfer", n'existe pas (apparement l'attribut n'est pas valable sur un event jQuery), je test donc l'existence de cet attribut, et si il n'existe pas je passe par evt.originalEvent.dataTransfer

    je me suis donc permis de modifier le code.
    voir sur js fiddle

    RépondreSupprimer
  2. Désolé le lien du fiddle est mauvais.
    Voici le bon lien

    RépondreSupprimer
  3. Bonjour,
    Très sympa comme article pour comprendre le fonctionnement.
    Cependant, pour les personnes qui veulent juste un drag & drop sans vouloir tout recoder, angularui à un module qui marche très bien :)

    RépondreSupprimer
  4. Merci beaucoup pour cet article et merci également à Joseph car j'ai eu le même problème avec l'objet dataTransfer :)

    RépondreSupprimer
  5. je n'arrive pas à l'intégrer sous firefox. Vous avez réussi ?

    RépondreSupprimer
  6. Réctificatif. Dans un projet solo firefox marche très bien. Dans l'intégration de mon projet j'ai du modifier un bout de code



    evt.originalEvent.dataTransfer.setData( "id", evt.target.id );
    evt.originalEvent.dataTransfer.effectAllowed = 'move';


    au lieu de :


    evt.dataTransfer.setData( "id", evt.target.id );
    evt.dataTransfer.effectAllowed = 'move';


    et la c'est bon ca marche.




    En revanche sur ie9 c'est censé fonctionner ? Car aucun drag de détecter

    RépondreSupprimer
  7. Article intéressant mais attention, cela ne fonctionne pas sur mobiles:
    http://caniuse.com/#feat=dragndrop

    Il faut aller voir du coté des touch events pour ça.

    Je dis ça car l'intro peut porter à confusion ("Le drag & drop est revenu à la mode avec l'arrivée des tablettes et des smartphones.").

    RépondreSupprimer
  8. erf, le fiddle ne fonctionne pas https://jsfiddle.net/4QwsP/1/

    RépondreSupprimer