How scrolling in Qt/QML ListViews is implemented
In order to display something in a listview, you need to provide a data model and a delegate. The delegate defines how each data item from the model is displayed. By default, QML will not create all list entries (aka. delegates) upfront. Instead, the engine will create and show only visible entries as well as a few additional ones (for caching). This results in faster loading times and less memory usage compared to an approach where all entries are created upfront.
When scrolling, additional list entries are created on-demand. QML will create a delegate for each newly visible model entry. At the same time, delegates, that become invisible and move out of the cached range are destroyed.
Creating simple items is fast and cheap, whereas the creation of complex QML objects can become quite slow (e.g. items containing several text elements, buttons, icons and logic).
The QML ListView prior to Qt 5.15 gives you the cache buffer property to tweak the caching behavior. It allows you to adjust the pixel range, in which delegates will be created and not be destroyed. You pay for it with an increase in memory usage and loading time of your QML scene. Increasing the cache buffer is okay on small static lists, it won’t help you on lists with a dynamic amount of entries.
Since the issue is caused by the creation and destruction of those complex delegates, wouldn’t it be nice to keep the delegates and reuse them? While this is not a built-in option pre Qt 5.15, we can use the mechanics of QML, in combination with a little help of JavaScript, to achieve the same result!
In order to do that, we need to hook into the creation and destruction of delegates by the list view. This can be done via the Component.onCompleted
and Component.onDestruction
signals of the delegate. We will use this to create a custom delegate cache that will recycle delegates.
Let’s build a delegate cache
First, we add an item into our main.qml
. Then we give the it the id elementCache
, but you can call it however you like.
Said item also contains a JavaScript array called delegateCache
as well as two functions called getDelegate
and returnDelegate
.
Component.onCompleted
creates delegates upfront and pushes them into the cache. Note, if the cache is empty, additional delegates are created in the getDelegate
function and reused on demand.
Item {
id: elementCache
visible: false
property var delegateCache: []
function getDelegate() {
console.log("getDelegate, cache size", delegateCache.length)
if (delegateCache.length > 0)
{
return delegateCache.pop()
}
else
{
return delegateComponent.createObject(elementCache)
}
}
function returnDelegate( item ) {
console.log("returnDelegate", item, "size", delegateCache.length)
item.parent = elementCache
/*
reset all properties of the delegate
this is important to get rid of bindings
if you dont do this, you may experience crashes
i.e.
item.myProperty = ""
item.myBindedProperty = false
*/
item.anchors.fill = elementCache
item.name = ""
item.aStaticProperty = false
delegateCache.push( item )
}
Component.onCompleted: {
for (var i = 0; i < 10; ++i)
{
var element = delegateComponent.createObject(elementCache)
delegateCache.push(element)
}
}
Component {
id: delegateComponent
MyComplexDelegate {}
}
}
Now let us build a ListView
, that uses the cached delegates.
ListView {
id: listView
anchors.fill: parent
model: myModel
delegate: Item {
id: container
height: 40
width: parent.width
property Item item
Connections {
target: item
ignoreUnknownSignals: true
onButtonClicked: {
console.log("HELLO WORLD")
}
}
Component.onCompleted:
{
item = elementCache.getDelegate()
item.parent = container
item.anchors.fill = Qt.binding(function (){ return container })
item.name = Qt.binding(function (){
return model.NameRole
})
item.aStaticProperty = model.constantBoolRole
}
Component.onDestruction:
{
elementCache.returnDelegate(item)
}
}
}
In Component.onCompleted
of the container delegate, the code gets one cached delegate from our elementCache,
by calling elementCache.getDelegate()
. Next, we simply change the parent of the delegate to the container.
In the next steps, the code creates bindings and sets static properties.
In Component.onDestruction
, the code calls elementCache.returnDelegate(item)
. This ensures that before the container is destroyed, the actual delegate will be pushed back into the cache (in returnDelegate
the parent of the delegate will be set back to elementCache
).
Working with Model Data
Getting static data into the delegate can be done by setting a property.item.aStaticProperty = model.constantBoolRole
Getting dynamic data into the delegate can be done by creating a Qt.binding
object. item.name = Qt.binding(function (){ return model.NameRole})
Last we need to connect signals from within the pulled in delegate (such as button clicks) and populate them to the container, where we can setup the handler of the click.
This is done by adding a Connections
object to the container. It is able to handle multiple signals.
Connections {
target: item
ignoreUnknownSignals: true
onButtonClicked: {
console.log("HELLO WORLD")
}
}
That’s it! A simple straightforward, JavaScript based, fast scrolling list caching mechanism. It works – no matter how complex your delegates are, even in Qt.5.2 🙂
If you have any questions or remarks, just drop a comment down below.
2 Responses
Thanks for the nice blog post and the example.
I played around a bit with this example and found the scrolling seems not working correctly when I use the attached ScrollBar.
It does not scroll smoothly if we use the Scrollbar.
Opened a ticket in GitHub https://github.com/basysKom/qml_fast_scrolling/issues/1
Hi Arun,
we already discussed in the ticket on github. The conclusion is that the time is spend in ‘onCompleted’ of the delegate. Specifically on color change since this is causing color updates in 500 subsequent rectangles.
The example here is made to be extrem – I guess that it’s unlikely that you ever build a delegate that contains 500 rectangles and those 500 rectangles changes their color on creation time.
However there is a way to also improve performance here. You need to move the change of the color out of ‘onCompleted’ and trigger the change later – i.e. by an external signal (scrolling is done) or by a timer.
Once you have done so, scrolling should be smooth again.