Monday, November 12, 2012

GWT(2.4+) Drag & Drop in a Tree

For an interface I am working on for Green Motion Travel, I needed to create a file organizer.

The files are presented in a tree, and the organizer allows you to drag files and folders around within the tree.

To accomplish this, I decided to use GWT's new Drag And Drop features.

I couldn't find any real documentation on how to use it, but there is a video on the subject on youtube, here:
    Google I/O 2011: GWT + HTML5: A web developers dream!
The bit on Drag&Drop starts at about 21 minutes in.
It turned out to be fairly easy!

Where to start?

With GWT, you can add Drag & Drop to almost anything.
I wanted to add D&D to TreeItems.  As it turns out, TreeItems don't really work well as D&D targets.  The target somehow ends up being the tree itself, so that you can't really drop a file onto another treeitem.

So, instead of adding D&D to the TreeItem objects, I instead created HTML objects and set those as widgets in the tree, and then added D&D to those.  Labels would work too, of course, but I wanted to add icons to represent folders and files.  For this tutorial, I will just use Labels, as it is a bit cleaner.

To make a widget draggable, you must do the following:
  • Set the 'draggable' property
  • Add a drag start handler
Some widgets support a draggable property directly, however Label does not. So, we need to go to the element and set it there.
In the Drag Start Handler, you must set the event data, or some browsers will not allow you to drag it.
In my case, I don't really care what the data is.  Instead, I will set a class field member (called 'dragging') to remember what label is being dragged.
Finally, we will copy an image of the existing label as the drag image.

Making a Label Draggable

Label draggableLabel = new Label("drag me!");
dragLabel .getElement().setDraggable(Element.DRAGGABLE_TRUE);
dragLabel.addDragStartHandler(new DragStartHandler()
{
  @Override
  public void onDragStart(DragStartEvent event)
  {
    // Remember what's being dragged
    dragging = dragLabel;
    // Must set anyway for FireFox
    event.setData("text", "hi there");
    // Copy the label as the drag image
    event.getDataTransfer().setDragImage(getElement(), 10, 10);
  }
});

If you were looking closely, you might have noticed that dragLabel is not final, as it would have to be in the above code.  I didn't bother, as I have wrapped everything up into a class that extends Label, as you will see below.

To make a widget droppable, you must:
  • Add a DragOver handler
  • Add a Drop handler
Again, some widgets support dropping in the API, however Label does not.  So instead, you need to add it using addDomHandler.  No problem.
The DragOver handler doesn't have to do anything.  I will use it, however, to apply a style to the label.
To counter the drag-over style, I will also add a DragLeave event.

Making a Label Droppable

Label dropLabel = new Label("Drop onto me");
dropLabel.addDomHandler(new DragOverHandler()
{
    @Override
    public void onDragOver(DragOverEvent event)
    {
        dropLabel.addStyleName("dropping");
    }
}, DragOverEvent.getType());

dropLabel.addDomHandler(new DragLeaveHandler()
{
    @Override
    public void onDragLeave(DragLeaveEvent event)
    {
        dropLabel.removeStyleName("dropping");
    }
}, DragLeaveEvent.getType());

dropLabel.addDomHandler(new DropHandler()
{
    @Override
    public void onDrop(DropEvent event)
    {
        event.preventDefault();
        // Do something with dropLabel and dragging
        etc...
    }
}, DropEvent.getType());

BIG NOTE:  Make sure to call event.preventDefault() in the onDrop, or else the browser might navigate away from the current page!

That's about it for making my labels draggable and droppable.
For my situation, I needed to create:
  • Leaf nodes for files, that are draggable and not droppable.
  • Root folders that are droppable, but not draggable
  • Subfolders that are both draggable and droppable.
I wrapped all of the above up into a class, called DragDropLabel.
The DropHandler for this example will move the TreeItem of the source under the TreeItem of the drop target.
Here is the implementation of my DragDropLabel class:

class DragDropLabel extends Label 
{
     private static DragDropLabel dragging = null;
     final boolean droppable;
     public DragDropLabel(String text, boolean draggable, boolean droppable)
     {
         super(text);
         if (draggable)
         {
             initDrag();
         }
         if (droppable)
         {
             initDrop();
         }
         this.droppable = droppable;
         if (droppable)
         {
             addStyleName("droppable");
         }
         else if (draggable)
         {
             addStyleName("draggable");
         }
     }
    
     private void initDrag()
     {
         getElement().setDraggable(Element.DRAGGABLE_TRUE);
         addDragStartHandler(new DragStartHandler()
         {
             @Override
             public void onDragStart(DragStartEvent event)
             {
                 // Remember what's being dragged
                 dragging = DragDropLabel.this;
                 // Must set for FireFox
                 event.setData("text", "hi there");

                // Copy the label image for the drag icon
                // 10,10 indicates the pointer offset, not the image size.
                event.getDataTransfer().setDragImage(getElement(), 10, 10);
             }
         });
    }

     private void initDrop()
     {
         addDomHandler(new DragOverHandler()
         {
             @Override
             public void onDragOver(DragOverEvent event)
             {
                 addStyleName("dropping");
             }
         }, DragOverEvent.getType());

         addDomHandler(new DragLeaveHandler()
         {
             @Override
             public void onDragLeave(DragLeaveEvent event)
             {
                 removeStyleName("dropping");
             }
         }, DragLeaveEvent.getType());

         addDomHandler(new DropHandler()
         {
             @Override
             public void onDrop(DropEvent event)
             {
                 event.preventDefault();
                 if (dragging != null)
                 {
                     // Target treeitem is found via 'this';
                     // Dragged treeitem is found via 'dragging'.

                     TreeItem dragTarget = null;
                     TreeItem dragSource = null;
                     // The parent of 'this' is not the TreeItem, as that's not a Widget.
                     // The parent is the tree containing the treeitem.
                     Tree tree = (Tree)DragDropLabel.this.getParent();
 
                     // Visit the entire tree, searching for the drag and drop TreeItems
                     List<TreeItem> stack = new ArrayList<TreeItem>();
                     stack.add(tree.getItem(0));
                     while(!stack.isEmpty())
                     {
                         TreeItem item = stack.remove(0);
                         for(int i=0;i<item.getChildCount();i++)
                         {
                             stack.add(item.getChild(i));
                         }

                         Widget w = item.getWidget();
                         if (w != null)
                         {
                             if (w == dragging)
                             {
                                 dragSource = item;
                                 if (dragTarget != null)
                                 {
                                     break;
                                 }
                             }
                             if (w == DragDropLabel.this)
                             {
                                 dragTarget = item;
                                 w.removeStyleName("dropping");
                                 if (dragSource != null)
                                 {
                                     break;
                                 }
                             }
                         }
                     }
                     if (dragSource != null && dragTarget != null)
                     {
                         // Make sure that target is not a child of dragSource

                         TreeItem test = dragTarget;
                         while(test != null)
                         {
                             if (test == dragSource)
                             {
                                 return;
                             }
                             test = test.getParentItem();
                         }
                         dragTarget.addItem(dragSource);
                         dragTarget.setState(true);
                     }
                     dragging = null;
                 }
             }
         }, DropEvent.getType());
     }
 }


And so, with that class, now all I have to do is add some of them to a tree, like so:
Tree tree = new Tree();
RootPanel.get("main").add(tree);
  
// root is not draggable.
TreeItem root = new TreeItem(new DragDropLabel("root", false, true));
tree.addItem(root);
  
// Add some draggable and droppable folders to 'root'
root.addItem(new DragDropLabel("folder1", true, true));
root.addItem(new DragDropLabel("folder2", true, true));

etc...

Here is a screenshot of the tree in action, dragging a file onto a folder.



You can download the source code for the example here.
Select "File/Download" to save it.
Note that it does not contain the GWT libs to save space.

Please leave a comment if this has helped you out!


4 comments:

Unknown said...

Hello,

did you check it on IE9 ?

I think code is working only on FF or Chrome that supports HTML5 DnD. I think IE9 doesn't support HTML5 Dnd (at least on DIV elements).

....

JS said...

Hi Sylvain,

Sorry, no I don't have a computer with IE9 on it!

Anonymous said...

Hi Jamie,

nice blog, I've followed your instructions with my celltree and the droppable label event handling is not working.

Cheers,
Matt.

Anonymous said...

Hi Jamie,

nice blog, I've followed your instructions with my celltree and the droppable label event handling is not working.

Cheers,
Matt.