Unsolved Problem keeping ListView highlight in view after model update
-
The problem: When moving the highlight in a ListView, the ListView normally scrolls to keep the highlight in view. However, if the model changes and the highlight is moved while the highlight is not fully visible, as in the example below, the ListView stops following the highlight... and stays that way.
For background, my actual application displays a list of script steps and you can add, delete, scroll, position and single-step the script steps, creating and debugging a small program if you will. This minimal example displays colored boxes instead of steps, with one red step always the last one. I actually use a C++ model, but QML ListModel demonstrates the problem just as well.
main.cpp:
#include <QGuiApplication> #include <QQmlApplicationEngine> int main(int argc, char *argv[]) { QGuiApplication app(argc, argv); QQmlApplicationEngine engine; engine.load("qrc:/main.qml"); return app.exec(); }
main.qml:
import QtQuick 2.12 import QtQuick.Window 2.12 import QtQuick.Controls 2.12 import QtQuick.Layouts 1.12 import QtQml 2.12 Window { width: 400 height: 600 visible: true title: "Example" ListModel { id: list ListElement { red: 1; green: 0; blue: 0 } // Last element, always displayed } ListView { id: view anchors { top: parent.top; left: parent.left; right: parent.right } height: parent.height*9/10 spacing: 5 clip: true highlightFollowsCurrentItem: true // This is the default, but for emphasis highlightMoveDuration: 200 model: list currentIndex: 0 highlight: Rectangle { z: 3 color: "transparent" border { color: "black"; width: 5 } Text { anchors.centerIn: parent text: "current" } } delegate: Rectangle { width: parent.width height: parent.width/5 color: Qt.rgba(red, green, blue, 1) MouseArea { anchors.fill: parent onClicked: view.currentIndex = index; } } } RowLayout { anchors { top: view.bottom; left: parent.left; right: parent.right; bottom: parent.bottom } Button { text: "add" // Add new step and move down onClicked: { list.insert(view.currentIndex, { red: Math.random(), green: Math.random(), blue: Math.random() }); ++view.currentIndex; } } Button { text: "del" // Delete last added step and move up onClicked: { if (view.currentIndex > 0) { list.remove(view.currentIndex-1, 1); --view.currentIndex; } } } Button { text: "step" // Step one element down onClicked: { if (view.currentIndex < list.count-1) { ++view.currentIndex; } } } } }
Press "add" 6 times and the current item starts disappearing outside the visible area. However, scroll down, select a different item than the last, then select the last item again.
Now observe the desired behavior, (i.e. my desired behavior... which happens after at least one update of "currentIndex" that is not combined with a simultaneous model update). We can now add new items, remove, reposition and step while the current item and highlight is always kept within visible range. It is not actually documented that this behavior should be achievable, I believe, but it sure is nice and just what I want. (When using my C++ model the setting of "currentIndex" happens in a different manner, so I am actually able to get into this desired state from the start.)
However, if you drag the view so that the current item is not entirely visible, then add or delete an item, the desired behavior derails, the view no longer scrolls to keep the current item in view. It seems that the model update combined with the change of currentIndex, combined with current item not being entirely visible causes it. It stays derailed until an independent update of "currentIndex" occurs. If currentIndex is set by mouse or by the "step" button, the desired behavior starts working again, using the "step" button even with the current item outside view, the view scrolls into position again. It seems that the change of currentIndex without a simultaneous model update makes it work again.
I have tried all kinds of of things, like "onCurrentIndexChanged: positionViewAtIndex(currentIndex, ListView.Contain)" (surely, that should work!), using "highlightRangeMode" in various ways, and using Timer (with interval: 0) to set the currentIndex or positionViewAtIndex "later". Nothing I have tried works. I have studied the source of qquickitemview.cpp, to see if I can understand what derails and en-rails the desired behavior (my current guess is "trackedItem" being reset). I did not actually debug the Qt source to see what is happening (it seems like a lot of work to set this up).
I don't really want to fork and fix the Qt code to make it work, even though this looks like a bug, I would prefer to make this work with Qt as it is. Surely, by wriggling around with the code long enough it should be possible.
-
This is a rare problem, I suppose, but for completeness in case anyone else encounters it, I will post my findings and solution:
- When view.currentIndex is set explicitly, from my code, so that ListView recognizes that it happened by an external event, then the visible area will scroll to keep the highlight visible the way I want.
- In the "add" button, when list.insert is used at (or above) view.currentIndex, then view.currentIndex is moved implicitly, by the ListView itself, because we inserted an element before the current element. In that case my following statement, "++view.currentIndex" only sets currentIndex to the value it already has, and that doesn't count (for ListView) as actually setting it... ListView doesn't get into the special state where: You set it, so we should keep track of it. Since currentIndex started out as 0, it will always be moved implicitly and we never get into this state.
My solution was to make ListView aware that I am actually explicitly setting currentIndex, by setting it twice, first to the value it had, then to the new value. As if writing Button "add" onClicked like this:
onClicked: { var currentIndex = view.currentIndex; list.insert(currentIndex, ...); // Now, view.currentIndex is already moved, so move it back and forth view.currentIndex = currentIndex; view.currentIndex = currentIndex+1; }
(I didn't try this code, and I won't bother, perhaps the code above will be optimized away, but I did it basically in this way, but from my connected C++ code and changing currentIndex by emitting a signal)
One might say that, despite the surprising outcome for me, ListView is working as designed, in that case the issue can be set as "solved".
However, I would argue that it would be more logical if ListView was changed to automatically enter this "keep highlight in view" state from the start.
-
To put it differently (illustrating why it may be working "as designed"):
ListView has two modes. In the first mode we have a list, and there is a highlighted "current item" somewhere, perhaps scrolled out of sight, but we are not tracking it anymore. By scrolling, for instance, we immediately enter this mode. In this mode, inserting new elements will not try to keep the highlight in view, even if the highlight was in view before inserting. Fair enough, if you are scrolling you must perhaps give up on tracking the current item.
The other mode happens after a particular item has been selected by the application by setting currentIndex. Now modifications of the list will cause the ListView to scroll to keep the highlight in view.
My problem was that:
- ListView is in the first, non-tracking mode by default
- My operations on the ListView model always moved currentIndex implicitly, in exactly the same way as I was intending to move it explicitly
- My explicit setting of currentIndex, always to the value that it already had never triggered the tracking mode. This does perhaps not trigger onCurrentIndexChanged so it may be impossible to detect for the underlying code
I can think of five separate ways that each would fix the issue:
- Set the ListView to the "tracking" mode on startup. Or:
- On scrolling (or in any state/operation), whenever the current item is left entirely contained within the visible range, automatically enter "tracking" mode (especially if "hichlightFollowsCurrentItem"). (Preferred.) Or:
- On model changes, whenever the current item is already within the visible range, automatically enter "tracking" mode before starting to visualize the model change. Or:
- Find out and fix why this code doesn't fix the problem: "onCurrentIndexChanged: positionViewAtIndex(currentIndex, ListView.Contain)". Or:
- A "setCurrentItem" method that always triggers tracking mode, even if we are setting the index to what it already is