Aurélien Gâteau

Reordering a Qt Quick ListView via drag'n'drop - part 1

written on Sunday, April 24, 2016

It is common in user interfaces to provide the user with a list of elements which can be reordered by dragging them around. Displaying a list of elements with Qt Quick is easy, thanks to the ListView component. Giving the user the ability to reorder them is less straightforward. This 3 article series presents one way to implement this.

The goal of this first article is to create a list which can be used like this:

Reordering elements in a ListView

Architecture

The approach I used was to do all the work in a DraggableItem, leaving the ListView untouched. DraggableItem is used as the delegate of the ListView, and wraps the real QML item responsible for showing the details of the list element.

Lets start with main.qml. Nothing fancy at the beginning, we create a Window and a ListModel defining our elements:

import QtQuick 2.6
import QtQuick.Window 2.2
import QtQuick.Controls 1.4
import QtQuick.Layouts 1.1

Window {
    visible: true
    width: 500
    height: 400

    ListModel {
        id: myModel
        ListElement {
            text: "The Phantom Menace"
        }
        ListElement {
            text: "Attack of the Clones"
        }
        ListElement {
            text: "Revenge of the Siths"
        }
        ListElement {
            text: "A New Hope"
        }
        ListElement {
            text: "The Empire Strikes Back"
        }
        ListElement {
            text: "Return of the Jedi"
        }
        ListElement {
            text: "The Force Awakens"
        }
    }

Now comes the main Item. It contains a ColumnLayout which holds a Rectangle faking a toolbar and our ListView, wrapped in a ScrollView:

Item {
    id: mainContent
    anchors.fill: parent
    ColumnLayout {
        anchors.fill: parent
        spacing: 0

        Rectangle {
            color: "lightblue"
            height: 50
            Layout.fillWidth: true

            Text {
                anchors.centerIn: parent
                text: "A fake toolbar"
            }
        }

        ScrollView {
            Layout.fillWidth: true
            Layout.fillHeight: true
            ListView {
                id: listView
                model: myModel
                delegate: DraggableItem {
                    Rectangle {
                        height: textLabel.height * 2
                        width: listView.width
                        color: "white"

                        Text {
                            id: textLabel
                            anchors.centerIn: parent
                            text: model.text
                        }

                        // Bottom line border
                        Rectangle {
                            anchors {
                                left: parent.left
                                right: parent.right
                                bottom: parent.bottom
                            }
                            height: 1
                            color: "lightgrey"
                        }
                    }

                    draggedItemParent: mainContent

                    onMoveItemRequested: {
                        myModel.move(from, to, 1);
                    }
                }
            }
        }

We can see DraggableItem used as a delegate of the ListView. Its API is simple: it wraps another item which shows the content (here it is a rectangle with a text and a one-pixel border at the bottom).

DraggableItem has one property: draggedItemParent, which defines which item becomes the parent of our content item while it is being dragged around. Setting this to the main content of your window gives a more natural feeling when you drag the item below or above the ListView: the item is not clipped to its view and appears on top of the other UI elements.

It also has a signal: moveItemRequested, which is emitted when the user dragged an item from one place to another. In this example we use ListModel.move to react to this but if you use a custom model you could call any other method.

DraggableItem implementation

DraggableItem contains a contentItemWrapper item, which is the parent of the DraggableItem child. When we start dragging, contentItemWrapper is reparented to the item specified in the draggedItemParent property of DraggableItem.

This is the beginning of DraggableItem.qml, it shows how contentItem is wrapped inside contentItemWrapper:

import QtQuick 2.0

Item {
    id: root

    default property Item contentItem

    // This item will become the parent of the dragged item during the drag operation
    property Item draggedItemParent

    signal moveItemRequested(int from, int to)

    width: contentItem.width
    height: contentItem.height

    // Make contentItem a child of contentItemWrapper
    onContentItemChanged: {
        contentItem.parent = contentItemWrapper;
    }

    Rectangle {
        id: contentItemWrapper
        anchors.fill: parent

Lets finish the definition of contentItemWrapper and continue with the code necessary to start the drag:

//
        Drag.active: dragArea.drag.active
        Drag.hotSpot {
            x: contentItem.width / 2
            y: contentItem.height / 2
        }

        MouseArea {
            id: dragArea
            anchors.fill: parent
            drag.target: parent
            // Keep the dragged item at the same X position. Nice for lists, but not mandatory
            drag.axis: Drag.YAxis
            // Disable smoothed so that the Item pixel from where we started the drag remains
            // under the mouse cursor
            drag.smoothed: false

            onReleased: {
                if (drag.active) {
                    emitMoveItemRequested();
                }
            }
        }
    }

    states: [
        State {
            when: dragArea.drag.active
            name: "dragging"

            ParentChange {
                target: contentItemWrapper
                parent: draggedItemParent
            }
            PropertyChanges {
                target: contentItemWrapper
                opacity: 0.9
                anchors.fill: undefined
                width: contentItem.width
                height: contentItem.height
            }
            PropertyChanges {
                target: root
                height: 0
            }
        }
    ]

A few things are worth noting here:

To create a draggable area, we use a MouseArea with the drag.target property set to the Item we want to drag.

In contentItemWrapper, we set Drag.active to dragArea.drag.active. If we did not do this, we would still be able to drag our Item, but DropArea would not notice it moving hover them (DropArea.containsDrag would remain false). We also define Drag.hotspot to the center of the dragged item. The hotspot is the coordinate within the dragged item which is used by DropArea to determine if a dragged item is over them.

When we start dragging, we change to the "dragging" state. In this state contentItemWrapper is reparented to draggedItemParent and the DraggableItem height is reduced to 0, completely hiding it.

Unless you associate data to your drag, for example to implement dragging from an application to another, the DropArea won't emit the dropped signal. This is why we trigger the move in the handler of the MouseArea released signal.

Dropping

Now that we have the "drag" part, we need to take care of the "drop" part.

Each DraggableItem contains a DropArea which is the same size as the DraggableItem and is positioned between its DraggableItem and the one next to it. This way when the user drops an item on a DropArea, we know we have to insert the dragged item after the item which owns the DropArea.

There is a special case though: we also want the user to be able to drop an item before the first item. To handle this, the first DraggableItem of the list is going to be special: it will have another DropArea, with its vertical center aligned to the top edge of the DraggableItem.

This diagram should make it clearer:

DropArea positions

As you can see, "Item 0" has two DropArea, whereas the other items only have one. Here is the code which adds the DropAreas:

Loader {
    id: topDropAreaLoader
    active: model.index === 0
    anchors {
        left: parent.left
        right: parent.right
        bottom: root.verticalCenter
    }
    height: contentItem.height
    sourceComponent: Component {
        DraggableItemDropArea {
            dropIndex: 0
        }
    }
}

DraggableItemDropArea {
    anchors {
        left: parent.left
        right: parent.right
        top: root.verticalCenter
    }
    height: contentItem.height
    dropIndex: model.index + 1
}

We use a Loader to create the special DropArea for the first item of the list. DraggableItemDropArea is just a DropArea with a dropIndex property and a Rectangle to show a drop indicator. Before showing its code, lets finish the code of DraggableItem. The only remaining part is the function responsible for emitting the moveItemRequested signal:

function emitMoveItemRequested() {
    var dropArea = contentItemWrapper.Drag.target;
    if (!dropArea) {
        return;
    }
    var dropIndex = dropArea.dropIndex;

    // If the target item is below us, then decrement dropIndex because the target item is
    // going to move up when our item leaves its place
    if (model.index < dropIndex) {
        dropIndex--;
    }
    if (model.index === dropIndex) {
        return;
    }
    root.moveItemRequested(model.index, dropIndex);
}

That's it for DraggableItem.

DraggableItemDropArea

Not much complexity here, we will actually remove this component later in the series. Here is the code:

import QtQuick 2.0

DropArea {
    id: root
    property int dropIndex

    Rectangle {
        id: dropIndicator
        anchors {
            left: parent.left
            right: parent.right
            top: dropIndex === 0 ? parent.verticalCenter : undefined
            bottom: dropIndex === 0 ? undefined : parent.verticalCenter
        }
        height: 2
        opacity: root.containsDrag ? 0.8 : 0.0
        color: "red"
    }
}

DraggableItemDropArea adds a dropIndex property and a Rectangle to draw the 2 pixel red line indicating where the item is going to be dropped, with a small hack to position the Rectangle correctly for the special case of the top DropArea of the first DraggableItem.

That's it for this first article in the series. You can find the source code in the associated GitHub repository, under the "1-base" tag. You can now continue to the next article of the series.

This post was tagged Drag'n'drop, QML and Qt
blog comments powered by Disqus