Delayed Loading Of Delegates in QML Views

In your QML code, you will encounter times when you will have to execute something slightly later. Qt offers a method called Qt.callLater(), using which you can schedule a function for later execution. Additionally, it also ensures that multiple calls to the same function are batched into a single call. This is really very useful in QML code. Let’s look a very simple use-case:

Rectangle {
    // ....
    
    onWidthChanged: Qt.callLater(sizeGotChanged)
    onHeightChanged: Qt.callLater(sizeGotChanged)

    function sizeGotChanged() {
        // This function gets called whenever width
        // or height changes.
    }

    // ....
}

Here, the function sizeGotChanged() gets called whenever either width or height changes for whatever reason. If they both get changed back to back, then sizeGotChanged() is callLater()‘ed twice, but this will result in a single call to sizeGotChanged() at some point in the very near future.

I routinely use this mechanism to delay loading of heavy delegates in ListViews. For example, consider that we have a ListView which hooks up to a model and makes use of heavy delegates. By heavy, I mean a delegate that takes considerable amount of time (30ms+) to instantiate. Such delegates cause scrolling in ListView to get slow and thereby makes the view sluggish to use. Instead of loading the whole delegate, we could load only critical parts of the delegate upon instantiation and load non-critical parts later. For example:

ListView {
    // ....

    model: myModel
    delegate: myDelegate

    // ...
}

Component {
    id: myDelegate 

    Item {
        id: lightPart
        // ...

        Loader {
            active: false
            anchors.fill: parent
            sourceComponent: Item {
                id: heavyPart
                // ...
            }
            
            function initialize() { active = true }
            Component.onCompleted: Qt.callLater(initialize)

            // ....
        }

        // ...
    }
}

In the code snippet above, you can see how the delegate is broken down into light and heavy parts. The light part is loaded instantly, whereas the heavy part is loaded later by a Loader item. This is accomplished by calling later the initialize() method in the Loader, which basically sets active to true.

This works great for the most part, until you realize that this causes heavy part of the delegates to get loaded when users are not scrolling the ListView as fast as callLater() is calling the initialize() method. To address this, you may want to delay call to the initialize() method by 100ms for instance. During that time if the delegate scrolls into view and scrolls out, the heavy part is not loaded. To keep the user from getting perplexed about empty looking delegates, we could load a place holder image for those 100ms while the user is busy scrolling rapidly. This is a common technique used by many web-apps and even desktop apps.

Scrite uses place holder images to render scenes in a screenplay, while user is rapidly scrolling. Watch this video to know more: “Closing the Gaps – QML on Desktop” – Qt Developer Conference (qtdevcon.com)

In such cases, Qt.callLater() is insufficient because it does not allow us to to specify how much later we want a method to be called. One way to address this would be to use a Timer.

ListView {
    // ....

    model: myModel
    delegate: myDelegate

    // ...
}

Component {
    id: myDelegate 

    Item {
        id: lightPart
        // ...

        Loader {
            id: heavyPartLoader
            anchors.fill: parent
            active: false
            sourceComponent: Item {
                id: heavyPart
                // ...
            }

            Timer {
                interval: 100
                running: true
                repeating: false
                onTriggered: heavyPartLoader.active = true
            }

            // ....
        }

        // ...
    }
}

While this works, the one issue with this approach is that we have a Timer object for each delegate instance, which has no purpose once the initial 100ms have passed and the heavy part of the delegate is loaded.

Instead of creating Timer objects statically like we did here, lets create them dynamically so that they auto-destroy themselves once they invoke the required callback.

/ Filename: utils.js
function execLater(contextObject, delay, callback, args) {
    var timer = Qt.createQmlObject("import QtQml 2.15; Timer { }", contextObject);
    timer.interval = delay === undefined ? 100 : delay
    timer.repeat = false
    timer.triggered.connect(() => {
                                callback(args)
                                timer.destroy()
                            })
    timer.start()
}

The execLater() method in this JavaScript code creates a Timer item, which self-destroys after invoking a callback upon timeout. The Timer item is created as a child of contextObject, which means that if for some reason the contextObject gets destroyed before the timeout duration, the Timer object itself gets destroyed.

Now in our QML code, we import this code into a name space and call the execLater() method. We also show a place holder image until the heavy part is actually loaded.

import "utils.js" as Utils

Component {
    id: myDelegate 

    Item {
        id: lightPart
        // ...

        Loader {
            id: heavyPartLoader
            anchors.fill: parent
            active: false
            sourceComponent: Item {
                id: heavyPart
                // ...
            }

            function initialize() { active = true }
            Component.onCompleted: {
                Utils.execLater(heavyPartLoader, 100, initialize)
            }

            Image {
                id: placeHolderImage
                anchors.fill: parent
                visible: parent.status !== Loader.Ready
                // ...
            }

            // ....
        }

        // ...
    }
}

This approach solves three problems

  1. We get to schedule call to a function a stated amount of time later, in this case 100 ms.
  2. We get to delete from memory the Timer object once its purpose is served.
  3. Until the heavy part of the delegate is loaded, we get to show a place holder image, letting the user know that some content is coming.

However, multiple calls to initialize() from Utils.execLater() won’t get batched into a single call to initialize(), like it does in Qt.callLater().

Additionally if you observe carefully, we have unwittingly thrown Loader and Image objects into memory, for each instance of the delegate. These objects are not needed once the heavy part is loaded. We will now need to further refactor our code to eliminate these objects.

import "utils.js" as Utils

Component {
    id: myDelegate 

    Item {
        id: lightPart
        // ...

        property Item placeHolderItem    
        Component.onCompleted: {
            placeHolderItem = placeHolderImageComponent.createObject(lightPart)
            Utils.execLater(lightPart, 100, loadHeavyPart)
        }

        property Item heavyPartItem
        function loadHeavyPart() {
            heavyPartItem = heavyPartComponent.createObject(lightPart)
            placeHolderItem.destroy()
        }

        // ...
    }
}

Component {
    id: heavyPartComponent

    Item {
        anchors.fill: parent
        // ...
    }
}

Component {
    id: placeHolderImageComponent

    Image {
        anchors.fill: parent
        // ...
    }
}

Here, you can notice that we have created the place holder image dynamically, which means we can destroy it once its purpose is served. You can also notice how we create the heavy part dynamically without using Loader.

The code below brings all of these concepts together into a working example:

// File: utils.js
function execLater(contextObject, delay, callback, args) {
    var timer = Qt.createQmlObject("import QtQml 2.15; Timer { }", contextObject);
    timer.interval = delay === undefined ? 100 : delay
    timer.repeat = false
    timer.triggered.connect(() => {
                                callback(args)
                                timer.destroy()
                            })
    timer.start()
}

function boundRandom(min, max) {
    return min + Math.random() * (max-min)
}

// File: example.qml
import QtQuick
import QtQuick.Window
import QtQuick.Controls
import "utils.js" as Utils

Window {
    width: 640
    height: 480
    visible: true

    ListView {
        id: myView
        anchors.fill: parent
        model: 300
        delegate: myDelegate
        ScrollBar.vertical: ScrollBar { }
    }

    Component {
        id: myDelegate

        Item {
            id: lightPart
            width: myView.width
            height: Utils.boundRandom(50, 300)

            required property int index

            property Item placeHolderItem
            Component.onCompleted: {
                placeHolderItem = 
                  placeHolderComponent.createObject(lightPart, {"index": index})
                Utils.execLater(lightPart, 1000, loadHeavyPart)
            }

            property Item heavyPartItem
            function loadHeavyPart() {
                heavyPartItem = 
                  heavyPartComponent.createObject(lightPart, {"index": index})
                placeHolderItem.destroy()
            }
        }
    }

    Component {
        id: heavyPartComponent

        Rectangle {
            required property int index

            anchors.fill: parent
            color: Qt.rgba( Utils.boundRandom(0.6,0.9),
                            Utils.boundRandom(0.6,0.9),
                            Utils.boundRandom(0.6,0.9), 1)

            Text {
                anchors.centerIn: parent
                text: "Heavy Part: " + index
                font.pixelSize: 18
            }
        }
    }

    Component {
        id: placeHolderComponent

        Rectangle {
            required property int index

            anchors.fill: parent
            color: Qt.rgba( Utils.boundRandom(0.6,0.9),
                            Utils.boundRandom(0.6,0.9),
                            Utils.boundRandom(0.6,0.9), 0.3)

            Text {
                anchors.centerIn: parent
                text: "Placeholder: " + index
                font.pixelSize: 18
            }
        }
    }
}

Leave a Reply

Your email address will not be published. Required fields are marked *