Introduction
This is a follow up article on the previous one which introduced simple concepts to build a Tree View in QML. It assumes that the previous article is completely read and understood. In this we will improve some hastily written code in the previous article with little better alternatives and more importantly we will learn how to move data around in the tree view as requested in one of the comments in Part-I.
Please read/refer the previous article here.
Using the code
The fundamentals of Tree-view construction remain the same - Component Recursion with an appropriate JS object-model and functionality to control the recursion. The JS model previously had 3 roles: "name
" for the name of the node in the tree-view, "level
" which was primarily used for indentation and "subNode
" for nested models (and hence ultimately Component Recursion). The 1st thing we will do here is add another role and call it "parentModel
". This will allow us to hold the parent node to the current node, hence every child will know the parent model that constructed it.
For sometime now, unless mentioned, we will be referring to the final code listing in the previous article.
So let's set up the parent node. There are two places in the code where we create new nodes for the tree. Both are inside the onAccepted
signal of the TextInput
. One is for the special case of level 0 and other for levels > 0. In both these places we will add the new mentioned role. Thus replace
objModel.append({"name": szSplit[0], "level": 0, "subNode": []})
with
objModel.append({"name": szSplit[0], "level": 0, "parentModel": objModel, "subNode": []})
and
node.subNode.append({"name": szSplit[i], "level": i, "subNode": []})
with
node.subNode.append({"name": szSplit[i], "level": i, "parentModel": node.subNode, "subNode": []})
The above code is easy - to whatever model we are appending a new object, pass that very model to the object to be created.
The code to expand/collapse nodes with sub-nodes via double clicking is modified to traverse all its immediate children and toggle their visibility if the objectName
is anything other than that of the MouseArea
itself (if the MouseArea is itself rendered invisible, we would have no way to bring back its visibility). This is done because QML repeater positioning within a parent View (Row, Column, GridView etc) seems bizzarre if the content is updated dynamically - I currently lack knowledge of the behavior. When repeater creates stuffs statically, it's easy to predict which child it itself is (0th, 1st, 2nd etc), but moment there is a dynamic update to the model of the repeater things take a subtle turn (try printing the objectNames of all children of a Row which has a Repeater which in turn has its model dynamically updated. I noticed the index of the repeater itself changes). In order to avoid complications, visibility is toggled based on filtering children via objectNames
. The loop to toggle visibility in the MouseArea
now looks like this:
onDoubleClicked: {
for(var i = 0; i < parent.children.length; ++i) {
if(parent.children[i].objectName !== "objMouseArea") {
parent.children[i].visible = !parent.children[i].visible
}
}
}
where objMouseArea
is the objectName
of the MouseArea
itself, the visibility of which must not be switched off.
To shuffle nodes, we will require drag-drop facilities and thus MouseArea
will define a draggable target and also will contain a DropArea
to accept drops. This is pretty standard for a drag-drop operation. In brief the concept is this: We give a drag target (eg., a Rectangle
) to the MouseArea
. This drag target will be dragged whenever there is a drag on the MouseArea
. The drag event makes
drag.active = true
for MouseArea
. This is what we monitor. Whenever there is an active drag we will "un-anchor" the drag target if it is anchored (otherwise it won't move :) ) and set its parent to the most-root item. This latter part is necessary because normally there would be lot of other components created after this drag target. Thus the drag target will be hidden as it travels across the screen whenever it comes across any component which was created after it, or after its parent etc. To avoid this, we will change its parent for the duration of drag to the most-root item so that the dragged object is always visible. This is done via
states: State {
when: ....
}
Similarly, we define a DropArea
(QML component). This recognizes active drags being entered, exited or dropped. Now an important point: in this article we will only want to shuffle items which have same immediate parent. We will restrict this with the drag-drop keys. Normally DropArea
will acknowledge every drag. If there are only a few which it must handle and reject the rest we need to make use of Drag.keys
of the source being dragged and keys
property of DropArea
. Then, only when these match does DropArea
acknowledge the drag (See Docs.). In our case we will use the stuff held by the role "parentModel
" as the drag as well as the drop keys. This "stuff" will actually be the address in memory of the parent model. Since all unique parent models will have different physical addresses in the memory, we will have achieved our goal of allowing only nodes under the same immediate parent to be shuffled. When the mouse is released (after the drag) we will check if the dragged object was dragged to a valid (accepting) DropArea
. This happens when the keys match and when it happens the dragged object's Drag.target
attached property will be non-null. We check for this condition on the release of the mouse and if it's non-null we fire a drop signal which will invoke onDropped
slot of the DropArea
(this is all QML behaviour - we don't need to make these connections - only call the appropriate signal). In the onDropped
slot of the DropArea
we will enquire about the index of the object/node which was being dragged and move it appropriately. For eg., if index 2 was dragged and released over index 4, index 4 object would be positioned at index 3 and 4th index would be occupied by the object with index 2. If index 4 object is dragged and released over index 2 object, then index 2 object would become the index 3 object and index 2 would now be occupied by the index 4 object. (Note the subtle difference between the lower index being released over a higher one and vice-versa).
To let the user know if the DropArea
for the chosen object/node is valid, we will use an animation to indicate it.
Here's a complete code listing that should (I hope) work without corrections:
import QtQuick 2.0
import QtQuick.Window 2.0
import QtQuick.Layouts 1.0
import QtQuick.Controls 1.0
Rectangle {
id: objRoot
objectName: "objRoot"
width: 600
height: 600
color: "black"
ListModel {
id: objModel
objectName: "objModel"
}
Component {
id: objRecursiveDelegate
Column {
id: objRecursiveColumn
objectName: "objRecursiveColumn"
property int m_iIndex: model.index
property var m_parentModel: model.parentModel
clip: true
MouseArea {
id: objMouseArea
objectName: "objMouseArea"
width: objRow.implicitWidth
height: objRow.implicitHeight
onDoubleClicked: {
for(var i = 0; i < parent.children.length; ++i) {
if(parent.children[i].objectName !== "objMouseArea") {
parent.children[i].visible = !parent.children[i].visible
}
}
}
drag.target: objDragRect
onReleased: {
if(objDragRect.Drag.target) {
objDragRect.Drag.drop()
}
}
Row {
id: objRow
Item {
id: objIndentation
height: 20
width: model.level * 20
}
Rectangle {
id: objDisplayRowRect
height: objNodeName.implicitHeight + 5
width: objCollapsedStateIndicator.width + objNodeName.implicitWidth + 5
border.color: "green"
border.width: 2
color: "#31312c"
DropArea {
keys: [model.parentModel]
anchors.fill: parent
onEntered: objValidDropIndicator.visible = true
onExited: objValidDropIndicator.visible = false
onDropped: {
objValidDropIndicator.visible = false
if(drag.source.m_objTopParent.m_iIndex !== model.index) {
objRecursiveColumn.m_parentModel.move(
drag.source.m_objTopParent.m_iIndex,
model.index,
1
)
}
}
Rectangle {
id: objValidDropIndicator
anchors.fill: parent
visible: false
onVisibleChanged: {
visible ? objAnim.start() : objAnim.stop()
}
SequentialAnimation on color {
id: objAnim
loops: Animation.Infinite
running: false
ColorAnimation { from: "#31312c"; to: "green"; duration: 400 }
ColorAnimation { from: "green"; to: "#31312c"; duration: 400 }
}
}
}
Rectangle {
id: objDragRect
property var m_objTopParent: objRecursiveColumn
Drag.active: objMouseArea.drag.active
Drag.keys: [model.parentModel]
border.color: "magenta"
border.width: 2
opacity: .85
states: State {
when: objMouseArea.drag.active
AnchorChanges {
target: objDragRect
anchors { horizontalCenter: undefined; verticalCenter: undefined }
}
ParentChange {
target: objDragRect
parent: objRoot
}
}
anchors { horizontalCenter: parent.horizontalCenter; verticalCenter: parent.verticalCenter }
height: objDisplayRowRect.height
width: objDisplayRowRect.width
visible: Drag.active
color: "red"
Text {
anchors.fill: parent
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
text: model.name
font { bold: true; pixelSize: 18 }
color: "blue"
}
}
Text {
id: objCollapsedStateIndicator
anchors { left: parent.left; top: parent.top; bottom: parent.bottom }
width: 20
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
text: objRepeater.count > 0 ? objRepeater.visible ? qsTr("-") : qsTr("+") : qsTr("")
font { bold: true; pixelSize: 18}
color: "yellow"
}
Text {
id: objNodeName
anchors { left: objCollapsedStateIndicator.right; top: parent.top; bottom: parent.bottom }
text: model.name
color: objRepeater.count > 0 ? "yellow" : "white"
font { bold: true; pixelSize: 18 }
verticalAlignment: Text.AlignVCenter
}
}
}
}
Rectangle {
id: objSeparator
anchors { left: parent.left; right: parent.right; }
height: 1
color: "black"
}
Repeater {
id: objRepeater
objectName: "objRepeater"
model: subNode
delegate: objRecursiveDelegate
}
}
}
ColumnLayout {
objectName: "objColLayout"
anchors.fill: parent
ScrollView {
Layout.fillHeight: true
Layout.fillWidth: true
ListView {
objectName: "objListView"
model: objModel
delegate: objRecursiveDelegate
interactive: false
}
}
Window {
id: objModalInput
objectName: "objModalInput"
modality: Qt.ApplicationModal
visible: false
height: 30
width: 200
color: "yellow"
TextInput {
anchors.fill: parent
font { bold: true; pixelSize: 20 }
verticalAlignment: TextInput.AlignVCenter
horizontalAlignment: TextInput.AlignHCenter
validator: RegExpValidator {
regExp: /(\d{1,},)*.{1,}/
}
onFocusChanged: {
if(focus) {
selectAll()
}
}
text: qsTr("node0")
onAccepted: {
if(acceptableInput) {
objModalInput.close()
var szSplit = text.split(',')
if(szSplit.length === 1) {
objModel.append({"name": szSplit[0], "level": 0, "parentModel": objModel, "subNode": []})
}
else {
if(objModel.get(parseInt(szSplit[0])) === undefined) {
console.log("Error - Given node does not exist !")
return
}
var node = objModel.get(parseInt(szSplit[0]))
for(var i = 1; i < szSplit.length - 1; ++i) {
if(node.subNode.get(parseInt(szSplit[i])) === undefined) {
console.log("Error - Given node does not exist !")
return
}
node = node.subNode.get(parseInt(szSplit[i]))
}
node.subNode.append({"name": szSplit[i], "level": i, "parentModel": node.subNode, "subNode": []})
}
}
}
}
}
Button {
text: "add data to tree"
onClicked: {
objModalInput.show()
}
}
}
}
Operations (in order):
<1> click on "add data to tree" button -> enter "node0" (without quotes) and press carriage-return.
<2> click on "add data to tree" button -> enter "node1" (without quotes) and press carriage-return.
<3> click on "add data to tree" button -> enter "node2" (without quotes) and press carriage-return.
<4> click on "add data to tree" button -> enter "node3" (without quotes) and press carriage-return.
<5> click on "add data to tree" button -> enter "1,node0" (without quotes) and press carriage-return.
<6> click on "add data to tree" button -> enter "1,node1" (without quotes) and press carriage-return.
<7> click on "add data to tree" button -> enter "1,node2" (without quotes) and press carriage-return.
<9> click on "add data to tree" button -> enter "1,node3" (without quotes) and press carriage-return.
<10> click on "add data to tree" button -> enter "1,2,node0" (without quotes) and press carriage-return.
<11> click on "add data to tree" button -> enter "1,2,node1" (without quotes) and press carriage-return.
<12> click on "add data to tree" button -> enter "1,2,node2" (without quotes) and press carriage-return.
<13> click on "add data to tree" button -> enter "1,2,node3" (without quotes) and press carriage-return.
<14> click on "add data to tree" button -> enter "3,nodeA" (without quotes) and press carriage-return.
<15> click on "add data to tree" button -> enter "3,nodeB" (without quotes) and press carriage-return.
<16> click on "add data to tree" button -> enter "3,nodeC" (without quotes) and press carriage-return.
<17> click on "add data to tree" button -> enter "3,nodeD" (without quotes) and press carriage-return.
<18> click on "add data to tree" button -> enter "3,1,nodeQ" (without quotes) and press carriage-return.
<19> click on "add data to tree" button -> enter "3,1,nodeR" (without quotes) and press carriage-return.
<20> click on "add data to tree" button -> enter "3,1,nodeS" (without quotes) and press carriage-return.
<21> click on "add data to tree" button -> enter "3,1,nodeT" (without quotes) and press carriage-return.
Double-click nodes to expand/collapse.
Drag nodes over each other slowly first to see what places the drop will be allowed (there will be an animated indication for allowed drop areas). Move nodes around to get a feel. Moving nodes around in expanded stated might be a bit visually confusing (although the code will do the correct thing). If so, 1st collapse the node and then move it around.
That's all. Use, complicate, improve and enjoy (and maybe rate this article if you find it worthy of being rated) !!