qtqmltreeviewqt5

How could I make a Expand All / Collapse All function for my custom QML TreeView?


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 !


Solution

  • 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.

    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));
            }
        }
    }
    

    Other Modifications:

    In my opinion, the current code requires significant refactoring. However, I have made some changes to make the source code cleaner and shorter:

    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);
                }
            }
        }
    }