For a Qt project based on Python for the backend and QML for the frontend, I made a custom component used to display data as a tree structure. This component uses a ListModel, the name of the field in the model where the name of the data is stored (so I can reuse this component for any model I have), the name of the field in the model containing the id, and the name of the field of the model containing the data's parent ID (for the same reason).
Each data can have a parent, so the component handles this by displaying a round button on the data row if the data has at least one child, and not displaying it when a data has no children.
The component is using Qt's Loader component to handle the recursion of the data's display.
I would like to understand how I could implement an expandAll and collapseAll function with my current method of handling data hierarchy, and furthermore, how would I be able to display data based on its id, for example:
name: "Data 1", id: 1, parentId: 0
|
|
|________ name: "Data 2", id: 2, parentId: 1
| |
| |
| |________ name: "Data 3", id: 3, parentId: 2
|
|
|________ name: "Data 4", id: 4, parentId: 1
|
|
|________ name: "Data 5", id: 5, parentId: 4
For example, how would I be able to make my tree structure display "Data 1", "Data 2", "Data 4" and "Data 5" if I would like to see the "Data 5" ? In this way, I mean how could all the above parents of a data use the expand method declared in my code so the entire path leading to a specific data would be displayed ?
Here is the code I'm currently using. Many thanks to Stephen Quan who helped me understanding how to display data's in QML in a previous post :
main.qml :
//Edit : Added a simple example of customerDataModel :
ListModel {
id: customerDataModel
ListElement {
CustomerID: 1
CustomerName: "Google"
CustomerParentId: 0
}
ListElement {
CustomerID: 2
CustomerName: "Amazon"
CustomerParentId: 0
}
ListElement {
CustomerID: 3
CustomerName: "Amazon US"
CustomerParentId: 2
}
ListElement {
CustomerID: 4
CustomerName: "Amazon EU"
CustomerParentId: 2
}
ListElement {
CustomerID: 5
CustomerName: "Amazon FR"
CustomerParentId: 4
}
ListElement {
CustomerID: 6
CustomerName: "Apple"
CustomerParentId: 0
}
ListElement {
CustomerID: 7
CustomerName: "Apple NTH"
CustomerParentId: 6
}
}
TreeView {
id: customerTreeView
width: 500
height: 500
dataModel: customerDataModel
dataId: "CustomerID"
dataName: "CustomerName"
dataParentId: "CustomerParentId"
}
TreeView.qml :
import QtQuick 2.13
import QtQuick.Controls 2.5
import QtQuick.Layouts 1.3
Item {
property var dataTable: ([])
property var dataModel: []
property string dataId: ""
property string dataName: ""
property string dataParentId: ""
property var selectionList: []
signal selectionChanged()
height: parent.height
function getSelectionList() {
return selectionList;
}
function insertRecord(dataId, dataName, dataParentId) {
dataTable.push([dataId, dataName, dataParentId]);
}
function insertRecords(records) {
for (const [dataId, dataName, dataParentId] of records)
insertRecord(dataId, dataName, dataParentId)
}
function selectRecords(dataParentId) {
return dataTable.filter(d => d[2] === dataParentId);
}
function selectRecursive(dataParentId) {
let obj = ({ id :0 ,dataId: dataParentId });
for (const [dataId, dataName, _dataParentId] of selectRecords(dataParentId)) {
if (! ("nodes" in obj) ) obj.nodes = [];
obj.nodes.push({"id" :dataId , "dataName" :dataName, "nodes" : selectRecursive(dataId)});
}
return obj;
}
Rectangle {
id: rect
width: parent.width
height: parent.height
clip: true
radius: 10
RowLayout {
id: buttonRowLayout
implicitWidth: parent.width
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: 20
spacing: 20
RoundButton {
id: expandButton
implicitWidth: buttonRowLayout.width /2 - 10
implicitHeight: 30
radius: 10
Text {
anchors.verticalCenter: parent.verticalCenter
anchors.horizontalCenter: parent.horizontalCenter
font.pointSize: 10
text: "Expand All"
}
background: Rectangle {
id: expandButtonRect
anchors.fill: parent
radius: 10
}
MouseArea {
anchors.fill: parent
onClicked: {
appTreeView.expandAll()
}
}
}
RoundButton {
id: collapseButton
implicitWidth: buttonRowLayout.width /2 - 10
implicitHeight: 30
radius: 10
Text {
anchors.verticalCenter: parent.verticalCenter
anchors.horizontalCenter: parent.horizontalCenter
font.pointSize: 10
text: "Collapse All"
}
background: Rectangle {
id: collapseButtonRect
anchors.fill: parent
radius: 10
}
MouseArea {
anchors.fill: parent
onClicked: {
appTreeView.collapseAll()
}
}
}
}
ScrollView {
id: treeViewScrollView
anchors.top: buttonRowLayout.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
contentHeight: appTreeView.height
contentWidth: appTreeView.implicitWidth
anchors.margins: 20
clip: true
AppTreeView {
id: appTreeView
indentation: 0
}
}
}
Component.onCompleted: {
for (let i = 0; i < dataModel.count; i++) {
let item = dataModel.get(i);
insertRecord(item[dataId], item[dataName], item[dataParentId]);
}
let m = selectRecursive(0);
appTreeView.model = m;
}
}
AppTreeView.qml :
import QtQuick 2.13
import QtQuick.Controls 2.5
import QtQuick.Layouts 1.3
Column {
id: tv
property var model
property int indentation
function expandAll() {
//TODO
}
function collapseAll() {
//TODO
}
Repeater {
id : repeater
model: tv.model ? tv.model.nodes : 0
delegate: Column {
id : column
property bool isChecked: false
Row {
id: row
spacing: 10
height: 30
width: childrenRect.width
Item {
id: indentationItem
height: parent.height
width: indentation
}
RoundButton {
id: button
visible: {
var obj = modelData.nodes
if (! ("nodes" in obj) ) {
return false
} else {
return true
}
}
radius: 100
width: 25
height: width
text: "V"
rotation: isChecked ? 0 : -90
anchors.verticalCenter: parent.verticalCenter
anchors.left: indentationItem.right
background: Rectangle {
radius: 100
}
onClicked: {
isChecked = !isChecked;
if (isChecked) {
expand(modelData.nodes);
} else {
collapse();
}
}
}
CheckBox {
id: checkBox
checked: isItemSelected(modelData)
onCheckedChanged: {
if (checked === true) {
addToSelection(modelData);
} else if (checked === false){
removeFromSelection(modelData);
}
}
anchors.left: button.right
anchors.leftMargin: 10
anchors.verticalCenter: parent.verticalCenter
}
Rectangle {
id: rect
width: childrenRect.width
height: 30
anchors.verticalCenter: parent.verticalCenter
radius: 10
anchors.left: checkBox.right
anchors.leftMargin: 10
Text {
anchors.verticalCenter: parent.verticalCenter
id: text
text: modelData.dataName
}
}
}
Loader {
id: loader
}
function expand(modelData) {
loader.setSource(
"AppTreeView.qml",
{ model: modelData,
indentation: indentation + 30
}
);
}
function collapse() {
loader.source = "Blank.qml";
}
function addToSelection(modelData) {
if (!isItemSelected(modelData)) {
console.log("Adding", modelData.dataName, "to selection");
selectionList.push(modelData.id);
selectionChanged();
}
}
function removeFromSelection(modelData) {
var index = selectionList.indexOf(modelData.id);
if (index !== -1) {
console.log("Removing", modelData.dataName, "from selection");
selectionList.splice(index, 1);
selectionChanged();
}
}
function isItemSelected(modelData) {
return selectionList.includes(modelData.id);
}
}
}
}
And Blank.qml display nothing :
import QtQuick 2.13
import QtQuick.Controls 2.5
Item {
}
Thank you for your help !
Currently, I do not recommend this structure for a tree view solution. There are much better options available to display a tree view.
In this answer, I have attempted to keep the base code intact and just added functions such as expandAll
, collapseAll
, and expandPath
, along with some other modifications.
expandAll
is a recursive function that calls expandAll
for all children.collapseAll
just hides and collapses the base children. Based on the current approach, this would destroy the child items, resetting them to the collapsed state.expandPath
takes an array of IDs from the root to a specific child. For example, for (id: 5), [2, 4, 5]
should be provided. This array can also be retrieved from the pathToId
function in OctopusTreeView
.expandPath Function:
Here, I have also used a recursive approach and called expandPath
for inner children if the child is found.
I also have used a trick to filter and convert visibleChildren
into a JavaScript array.
To expand an item, you only need to set isChecked = true
, and the item will expand.
function expandPath(path) {
if (path && path.length) {
const current = path[0];
const nodes = Array.from(visibleChildren).filter(n => 'nodes' in n);
const node = nodes.find(n => n.nodeId == current);
if (node) {
node.isChecked = true; // Expand node
const innerItem = node.loader.item;
if (innerItem.expandPath) innerItem.expandPath(path.slice(1));
}
}
}
In my opinion, the current code requires significant refactoring. However, I have made some changes to make the source code cleaner and shorter:
component
for inline reusable items.MouseArea
inside buttons; they already have onClicked
and other signals.palette
to change the color of Control
components.main.qml
palette { // Try using palettes in Qt 5 to set button and window colors.
base: '#badfd7'
window: '#f1f2f3'
button: '#60bfc1'
highlight: '#fdb7b9'
text: '#343536'
windowText: '#343536'
buttonText: '#f1f2f3'
highlightedText: '#343536'
}
ListModel { /* ... */}
OctopusTreeView { /* ... */ }
OctopusTreeView.qml
Page {
id: page
property var dataTable: ([])
property var dataModel: []
property string dataId: ""
property string dataName: ""
property string dataParentId: ""
property var selectionList: []
signal selectionChanged()
function getSelectionList() {
return selectionList;
}
function insertRecord(dataId, dataName, dataParentId) {
dataTable.push([dataId, dataName, dataParentId]);
}
function insertRecords(records) {
for (const [dataId, dataName, dataParentId] of records) {
insertRecord(dataId, dataName, dataParentId);
}
}
function selectRecords(dataParentId) {
return dataTable.filter(d => d[2] === dataParentId);
}
function selectRecursive(dataParentId) {
let obj = ({ id :0 ,dataId: dataParentId });
for (const [dataId, dataName, _dataParentId] of selectRecords(dataParentId)) {
if (! ("nodes" in obj) ) obj.nodes = [];
obj.nodes.push({"id" :dataId , "dataName" :dataName, "nodes" : selectRecursive(dataId)});
}
return obj;
}
function pathToId(id) {
let path = [];
while(id) {
const target = dataTable.find(([i, n, p]) => i === id);
if(target) {
path.unshift(id);
id = target[2];
} else {
return undefined;
}
}
return path;
}
spacing: 5
padding: 5
component HeaderBtn: RoundButton {
Layout.fillWidth: true
Layout.fillHeight: true
radius: 3
font.bold: true
}
header: Control {
height: 35
padding: 3
contentItem: RowLayout {
spacing: 3
HeaderBtn {
text: "Expand All"
onClicked: appTreeView.expandAll();
}
HeaderBtn {
text: "Collapse All"
onClicked: appTreeView.collapseAll();
}
HeaderBtn {
text: "Expand id: 5 (Amazon FR)"
onClicked: appTreeView.expandPath(pathToId(5) ?? []);
}
}
}
contentItem: ScrollView {
id: treeViewScrollView
implicitHeight: contentHeight
clip: true
AppTreeView {
id: appTreeView
indentation: 0
}
}
Component.onCompleted: {
for (let i = 0; i < dataModel.count; i++) {
let item = dataModel.get(i);
insertRecord(item[dataId], item[dataName], item[dataParentId]);
}
appTreeView.model = selectRecursive(0);
}
}
AppTreeView.qml
Column {
id: tv
property var model
property int indentation
function expandAll() {
const nodes = Array().filter.call(visibleChildren, n => 'nodes' in n)
nodes.forEach(n => {
n.isChecked = true; // Expand node
const innerItem = n.loader.item;
if(innerItem && innerItem.expandAll) innerItem.expandAll();
});
}
function collapseAll() {
const nodes = Array().filter.call(visibleChildren, n => 'nodes' in n)
nodes.forEach(n => {
/// Based on the current solution, the inner items get destroyed, so there is no need for recursive traversal.
n.isChecked = false;
});
}
function expandPath(path) {
if(path && path.length) {
const current = path[0];
const nodes = Array().filter.call(visibleChildren, n => 'nodes' in n)
const node = nodes.find(n => n.nodeId == current);
if(node) {
node.isChecked = true; // Expand node
const innerItem = node.loader.item;
if(innerItem.expandPath) innerItem.expandPath(path.slice(1));
}
}
}
Repeater {
model: tv.model ? tv.model.nodes : 0
delegate: Column {
property alias isChecked: button.checked
property alias loader: loader
property string nodeId: modelData.id
property string nodeName: modelData.dataName
property var nodes: modelData.nodes
Grid {
spacing: 5
width: childrenRect.width
verticalItemAlignment: Qt.AlignVCenter
leftPadding: indentation + (button.visible ? 0 : (25 + spacing))
RoundButton {
id: button
visible: "nodes" in modelData.nodes
checkable: true
radius: 5
width: 25; height: width
text: isChecked ? "-" : "+"
font.bold: true
onCheckedChanged: checked ? expand(nodes) : collapse();
}
CheckBox {
padding: 0
spacing: 0
checked: isItemSelected(modelData)
onCheckedChanged: checked ? addToSelection(modelData) :removeFromSelection(modelData)
indicator{ width: 25; height: 25 }
Component.onCompleted: indicator.radius = 3
}
Label {
id: text
text: nodeName
padding: 5
background: Rectangle {
border { width: 1; color: palette.mid }
radius: 3
}
}
}
Loader {
id: loader
}
function expand(modelData) {
loader.setSource("AppTreeView.qml", { model: modelData, indentation: indentation + 30 });
}
function collapse() {
loader.source = "Blank.qml";
}
function addToSelection(modelData) {
if (!isItemSelected(modelData)) {
console.log("Adding", modelData.dataName, "to selection");
selectionList.push(modelData.id);
selectionChanged();
}
}
function removeFromSelection(modelData) {
var index = selectionList.indexOf(modelData.id);
if (index !== -1) {
console.log("Removing", modelData.dataName, "from selection");
selectionList.splice(index, 1);
selectionChanged();
}
}
function isItemSelected(modelData) {
return selectionList.includes(modelData.id);
}
}
}
}