Ioannis Panagopoulos blog

Tutorials on HTML5, Javascript, WinRT and .NET

Drag and Drop in WinJS.UI.ListView for repositioning items in Windows Store Apps

by Ioannis Panagopoulos

After hours of searching in the documentation in order to implement a decent drag and drop behavior in WinJS.UI.ListView (like the one we see on the “start” screen) I have finally managed to make it happen and in this post I am sharing the way it can be done. During this post there are also answers about:

How to provide multiple templates for the ListView items.

How to “gesture enable” your DOM elements.

The main requirement is simple.

You have a WinJS.UI.ListView control on your Windows Store app and need to allow the user move some elements in the list (usually to allow the user to define via drag and drop their order in the list).

We will start with a very simple project where a demo list (dataList) is defined as follows:


var groups = [
    { key: 'group1', title: 'Group 1' },
    { key: 'group2', title: 'Group 2' }
];
var dataList = new WinJS.Binding.List();
dataList.push({ group: groups[0], data: { id: 1, name: 'one' } });
...
dataList.push({ group: groups[0], data: { id: 7, name: 'seven' } });

dataList = dataList.createGrouped(
        function (item) {
            return item.group.key;
        },
        function (item) {
            return item.group;
        });


That is we create a WinJS.Binding.List object with 7 elements all having their property group pointing to groups[0] (which means that they will belong to the same group.Later we will change that) and some demo object in their data property. We then use the createGrouped method passing as arguments the function that defines the id of the group each item belongs to and the function that returns the actual group object for each item. Now our dataList is ready to be the source of a WinJS.UI.ListView control. The control in HTML is defined with two templates (one for the group items with class name “.groupTemplate” and one for the list items with class name “.itemTemplate”).


<div class="groupTemplate" data-win-control="WinJS.Binding.Template">
    <span data-win-bind="textContent: title"></span>
</div>
<div class="itemTemplate" data-win-control="WinJS.Binding.Template">
    <div style="width:200px;height:200px;background-color:green">
        <div data-win-bind="textContent:data.name" style="padding:20px"></div>
    </div>
</div>
<div data-win-control="WinJS.UI.ViewBox">
    <div class="fixedlayout">
        <div id="demoListView" style="width:100%;height:100%;" data-win-control="WinJS.UI.ListView"></div>
    </div>
</div>

Note that although we could declarative define the templates in the div element of the ListView control, we choose not to do so since for the implementation of drag and drop we will have to add some code to the “item from template generation logic”. Therefore we initialize the templates via code as follows:


var demoListView = document.querySelector("#demoListView");
var demoListViewControl = demoListView.winControl;
demoListViewControl.layout = new WinJS.UI.GridLayout({
    groupHeaderPosition: "top",
    groupInfo: {
        enableCellSpanning: true,
        cellWidth: 100,
        cellHeight: 100
    }
});
demoListViewControl.groupHeaderTemplate = document.querySelector('.groupTemplate');
demoListViewControl.itemTemplate = function (itemPromise) {
    return itemPromise.then(function (item) {
        var itemTemplate = document.querySelector('.itemTemplate');
        var container = document.createElement("div");
        itemTemplate.winControl.render(item.data, container).done();
        return container;
    });
}
demoListViewControl.itemDataSource = dataList.dataSource;
demoListViewControl.groupDataSource = dataList.groups.dataSource;

The layout property is defined as a GridLayout object and we provide the enableCellSpanning, cellWIdth and cellHeight properties to allow multiple sizes for the item templates if needed (only remember that their size needs to be a multiple of the cellWidth and cellHeight properties taking into consideration the margins between them). We define a fixed header template for the display of the current group and then a function that returns the required template for the item. Usually the function is provided if we need to support multiple item template but in our case we use it to gain access to the items’ DOM elements.

Let’s first “gesture enable” our DOM Elements so that they can understand user gestures (meaning different “kinds” of mouse or touch interactions such as tap, hold, swipe). To do this we need to run a function like the following for the DOMElement:


prepareDOMElementForGestures:function (DOMElement) {
    var msGesture = new MSGesture();
    msGesture.target = DOMElement;
    DOMElement.gesture = msGesture;

    DOMElement.addEventListener("MSGestureStart", _gestureHandler, false);
    DOMElement.addEventListener("MSGestureEnd", _gestureHandler, false);
    DOMElement.addEventListener("MSGestureChange", _gestureHandler, false);
    DOMElement.addEventListener("MSInertiaStart", _gestureHandler, false);
    DOMElement.addEventListener("MSGestureTap", _gestureHandler, false);
    DOMElement.addEventListener("MSGestureHold", _gestureHandler, false);
    DOMElement.addEventListener("MSPointerDown", _gestureHandler, false);
}


This adds the appropriate event handlers and assigns the MSGesture object to the DOM element. The handler “_gestureHandler” is as follows:
_gestureHandler :function (event) {
    if (event.currentTarget.gesture.target == null)
        event.currentTarget.gesture.target = event.currentTarget;
    if (event.type == "MSPointerDown") {
        event.currentTarget.gesture.addPointer(event.pointerId);
    }
    if (event.type == "MSGestureStart") {
        if (this._inDragAndDrop) {
            // initializations here
        }
    }
    if (event.type == "MSGestureHold") {
        this._inDragAndDrop = true;
    }
    if (event.type == "MSGestureChange") {
        if (this._inDragAndDrop) {
            // respond to mouse movement
        }
    }
    if (event.type == "MSGestureEnd") {
        if (this._inDragAndDrop) {
            // finalize
            this._inDragAndDrop=false;
        }
    }
    if (event.type == "MSGestureTap") {
    }
}

Some important points here is the you should not erate the MSPointerDown if block since it is needed for the handler. Also the highlighted in yellow part is a hack since sometimes the DOMElement looses its gesture object when added to the list and by doing this we reassign it in the first call of the handler. The other is straightforward. On Hold we set the drag and drop is ready to start. On Start we initialize, on Change we move and on End we will be checking what has happened. The tap gesture is needed since by enabling gestures on the DOMElement we are effectively “stealing” its click event.

Therefore to enable gestures in our list item’s DOM Elements we change the rendering line of the template generation function (and this is why we need it even though we only have one template) as follows:


demoListViewControl.itemTemplate = function (itemPromise) {
        ...
        itemTemplate.winControl.render(item.data, container).done(function (ev) {
            setTimeout(function () {
                dragAndDrop.prepareDOMElementForGestures(container.parentElement, true);
            }, 100);
        });
        ...
}

The whole idea lies at the fact that as we generate the DOM element for the list item, we enable it for the gestures. The timeout is needed to allow some time for the ListView to render the item in the document so we get the complete DOMElement. Now when the hold gesture is detected we set the boolean of drag and drop to true and then when gesture start is detected. So in “MSGestureStart” if block:


if (this._inDragAndDrop) {
    event.target.style.zIndex = 10000;
    WinJS.UI.Animation.dragSourceStart(event.currentTarget).done();
    this._initPos.left = parseInt(event.target.style.left.replace("px",""));
    this._initPos.top = parseInt(event.target.style.top.replace("px", ""));

    var listToScrollElement = this._listViewDOM.querySelector(".win-viewport.win-horizontal");
    this._offset.left = event.clientX + listToScrollElement.scrollLeft - this._initPos.left;
    this._offset.top = event.clientY - this._initPos.top;
}


We set the zIndex of the element to a big value to make it appear on top of everything else. We then animate the element to show that drag has started. We then store the initial position of the element (to return it if drop is cancelled) and we also calculate the offset of the mouse-touch position in relation to the top left corner of the DOMElement (this is to keep moving the element at the point where our mouse-touch pointer was initially detected and it also takes into consideration the scroll position).

Now during the movement of the mouse-finger (MSGestureChange detected) we need to move the DOM element and also detect which items are under it in order to animate them also to give the user some feedback on where the item will be placed if dropped. Finally we need to detect the bounds of the display in order to scroll the list if needed.

We will tackle those issues one by one but note that all the code mentioned in the following paragraphs actually belongs to the “MSGestureChange” if block. Dragged item movement is the simplest of all:


var x = event.clientX;
var y = event.clientY;
var listToScrollElement = this._listViewDOM.querySelector(".win-viewport.win-horizontal");
event.currentTarget.style.left = (x - this._offset.left + listToScrollElement.scrollLeft).toString() + "px";
event.currentTarget.style.top = (y - this._offset.top).toString() + "px";

Then we need to know what element is under the mouse (not the dragged but the other below). This is achieved by temporarily hiding the dragged element to reveal through document.elementFromPoint the element underneath.


// get the DOM element currently under the mouse-finger
event.currentTarget.style.display = 'none';
var DOMElement = document.elementFromPoint(x, y);
event.currentTarget.style.display = '';
// get the index of the actual list item that the DOMElement belongs to (if none –1 will be returned)
var idx = this._listViewControl.indexOfElement(DOMElement);

Now the whole idea is to know which two elements need to be moved as the item is being dragged

drag

Using the above code we know the currentOverElement1(named DOMElement) and if idx is not –1 we know that this is actually a part of the ListView and not something that does not participate in the drag and drop action. So if we detect an idx change and this change gives as an idx that is not –1 then we know that currentOverElement1=DOMElement and we need to figure out what the currentOverElement2’s value will be. Of course this applies when we move to the right of the list. When we move left then we know currentOverElement2 and we need to find currentOverElement1. We also need to check for limit conditions at the start – end of the list and revert the previous currentOverElements.All of the above are implememented right after the previously mentioned code block in (MSGestureChange)


if (this._idxOfcurrentOverElement != idx) { // this means that the element has changed since the last event
    // if there were any previous elements animated
    if (this._currentOverElement1) WinJS.UI.Animation.dragBetweenLeave(this._currentOverElement1);
    if (this._currentOverElement2) WinJS.UI.Animation.dragBetweenLeave(this._currentOverElement2);

    var draggedItemIdx = this._listViewControl.indexOfElement(event.currentTarget);
    this._idxOfcurrentOverElement = idx;
    // if there are some elements to be animated below the mouse-finger
    if (this._idxOfcurrentOverElement != -1) {
        if (draggedItemIdx > this._idxOfcurrentOverElement) { // we are moving to the left
            this._currentOverElement1 = undefined;
            this._currentOverElement2 = this._listViewControl.elementFromIndex(this._idxOfcurrentOverElement);
            if ((this._idxOfcurrentOverElement - 1) >= 0)
                this._currentOverElement1 = this._listViewControl.elementFromIndex(this._idxOfcurrentOverElement - 1);
        }
        else { // we are moving to the right
            this._currentOverElement1 = this._listViewControl.elementFromIndex(this._idxOfcurrentOverElement);
            this._currentOverElement2 = undefined;
            if ((this._idxOfcurrentOverElement + 1) < this._listViewControl.itemDataSource.getCount()._value)
                this._currentOverElement2 = this._listViewControl.elementFromIndex(this._idxOfcurrentOverElement + 1);
        }
        if (this._currentOverElement1)
            WinJS.UI.Animation.dragBetweenEnter(this._currentOverElement1, { left: this._movementOffset.left, top: '0px' });
        if (this._currentOverElement2)
            WinJS.UI.Animation.dragBetweenEnter(this._currentOverElement2, { left: this._movementOffset.right, top: '0px' });
    }
}


When the user drops the item since we know currentOverElement1 and 2 we know where the item is being dropped. If those are undefined we just restore the initial element’s position. Otherwise we call a method with the currentOverElement1,2 indexes as parameters that will manipulate the list and will return true if it has handled the placement or false if based on the logic the insertion should not be performed (MSGestureEnd if block).


this._inDragAndDrop = false;
event.target.style.zIndex = "";

if (this._currentOverElement1) WinJS.UI.Animation.dragBetweenLeave(this._currentOverElement1).done();
if (this._currentOverElement2) WinJS.UI.Animation.dragBetweenLeave(this._currentOverElement2).done();
WinJS.UI.Animation.dragSourceEnd(event.target, undefined, this._allDOMElements).done();

var idxOfLeftItem = this._listViewControl.indexOfElement(this._currentOverElement1);
var idxOfRightItem = this._listViewControl.indexOfElement(this._currentOverElement2);

var itemPlaced = false;
if (idxOfLeftItem != -1 || idxOfRightItem != -1)
    itemPlaced = this._dropCallback(this._listViewControl.indexOfElement(event.target), idxOfLeftItem, idxOfRightItem);
if (!itemPlaced) {
    event.target.style.left = this._initPos.left.toString()+"px";
    event.target.style.top = this._initPos.top.toString()+"px";
}


A possible implementation of the dropcallback can be as follows:


function (itemIdx, leftItemIdx, rightItemIdx) {
    var dropAfterItem;
    if (leftItemIdx != -1)
        dropAfterItem = dataList.getAt(leftItemIdx);

    var item = dataList.getAt(itemIdx);
    var result = false;
    if (!dropAfterItem) { //Insert at the beginning
        dataList.move(itemIdx, 0);
        result = true;
    }
    else {
        var desiredPos = leftItemIdx < itemIdx ? leftItemIdx + 1 : leftItemIdx;
            dataList.move(itemIdx, desiredPos);
            result = true;
    }
    return result;
}


Where we just check the indexes and perform some movements between the items in the list.

The only thing left to be added is the scrolling of the list while an item is being dragged. This is a matter of detecting the bounds of the displayed list and scrolling the view area while the mouse – finger remains in those bounds via a setTimeout. At the end of the drag and drop or when the mouse – cursor leaves those bounds you should remember to cancel the timeout. The implementation of the scrolling can be found in the demo project.

So this is what it takes to have drag and drop in WinJS.UI.ListView. A minor note to remember is that you will notice that dragBetweenLeave leaves a scale(0.95) transform to the element which I consider to be a library’s bug. If you find any solution to this please do contact me.

Download the demo project.

blog comments powered by Disqus
hire me