From 4e6853b0e78beb2aeb8150b64dc13784e34016c2 Mon Sep 17 00:00:00 2001 From: Vladimir Belyavsky Date: Fri, 25 Sep 2026 17:49:43 +0300 Subject: ScrollBar: don't jump the content when the content size is re-estimated A view with delegates of varying sizes keeps correcting its contentHeight estimate while the handle is being dragged, which changes the mapping from the ScrollBar's position to contentY. offset, the distance between the handle and the pointer, was taken once in handlePress() and never recalculated, so the content jumped against the direction of the drag after every correction. Recalculate offset whenever something other than the drag moves the handle or changes the size while it is pressed. An attached Flickable writes the drag's own position back through visibleArea, so such a write is recognised by value: a position equal to the current one has moved the handle nowhere. The handle may still move and resize as the estimate is refined, which is expected; only the content is kept following the pointer. [ChangeLog][Controls][ScrollBar] Dragging the handle of a ScrollBar that is attached to a view whose delegates vary in size no longer makes the content jump as the view refines its content size estimate. Task-number: QTBUG-130890 Pick-to: 6.12 Change-Id: I62447df8c43051896dc71f61263295f8a5acb7ac Reviewed-by: Mitch Curtis --- src/quicktemplates/qquickscrollbar.cpp | 36 ++++++- src/quicktemplates/qquickscrollbar_p_p.h | 4 + .../quickcontrols/controls/data/tst_scrollbar.qml | 106 +++++++++++++++++++++ 3 files changed, 143 insertions(+), 3 deletions(-) diff --git a/src/quicktemplates/qquickscrollbar.cpp b/src/quicktemplates/qquickscrollbar.cpp index 58963226e3..9353563eab 100644 --- a/src/quicktemplates/qquickscrollbar.cpp +++ b/src/quicktemplates/qquickscrollbar.cpp @@ -285,13 +285,13 @@ bool QQuickScrollBarPrivate::handlePress(const QPointF &point, ulong timestamp) qreal sz = qMax(size, logicalPosition(minimumSize)); if (offset < 0 || offset > sz) offset = sz / 2; + dragPoint = point; q->setPressed(true); return true; } bool QQuickScrollBarPrivate::handleMove(const QPointF &point, ulong timestamp) { - Q_Q(QQuickScrollBar); QQuickControlPrivate::handleMove(point, timestamp); /* @@ -307,7 +307,7 @@ bool QQuickScrollBarPrivate::handleMove(const QPointF &point, ulong timestamp) qreal pos = qMax(0.0, qMin(positionAt(point) - offset, 1.0 - size)); if (snapMode == QQuickScrollBar::SnapAlways) pos = snapPosition(pos); - q->setPosition(pos); + setPositionFromDrag(pos, point); return true; } @@ -327,8 +327,9 @@ bool QQuickScrollBarPrivate::handleRelease(const QPointF &point, ulong timestamp qreal pos = qMax(0.0, qMin(positionAt(point) - offset, 1.0 - size)); if (snapMode != QQuickScrollBar::NoSnap) pos = snapPosition(pos); - q->setPosition(pos); + setPositionFromDrag(pos, point); offset = 0.0; + dragPoint = {}; q->setPressed(false); return true; } @@ -338,6 +339,7 @@ void QQuickScrollBarPrivate::handleUngrab() Q_Q(QQuickScrollBar); QQuickControlPrivate::handleUngrab(); offset = 0.0; + dragPoint = {}; q->setPressed(false); } @@ -421,6 +423,8 @@ void QQuickScrollBar::setSize(qreal size) if (d->size + d->position > 1.0) { d->setPosition(1.0 - d->size, false); } + // positionAt() depends on the size. + d->reanchorDragOffset(d->position); if (isComponentComplete()) d->resizeContent(); @@ -458,6 +462,23 @@ void QQuickScrollBar::setPosition(qreal position) } void QQuickScrollBarPrivate::setPosition(qreal newPosition, bool notifyVisualChange) +{ + // An attached Flickable writes the drag's own position back here unchanged, and those + // writes must not touch offset - see setPositionFromDrag(). + if (qt_is_finite(newPosition) && !qFuzzyCompare(position, newPosition)) + reanchorDragOffset(newPosition); + updatePosition(newPosition, notifyVisualChange); +} + +void QQuickScrollBarPrivate::setPositionFromDrag(qreal newPosition, const QPointF &point) +{ + dragPoint = point; + // Bypass setPosition(): offset is already accounted for in newPosition, so re-anchoring it + // would fold the clamping and the snapping applied by the caller back into it. + updatePosition(newPosition, true /*notifyVisualChange*/); +} + +void QQuickScrollBarPrivate::updatePosition(qreal newPosition, bool notifyVisualChange) { Q_Q(QQuickScrollBar); if (!qt_is_finite(newPosition) || qFuzzyCompare(position, newPosition)) @@ -483,6 +504,15 @@ void QQuickScrollBarPrivate::setPosition(qreal newPosition, bool notifyVisualCha #endif } +void QQuickScrollBarPrivate::reanchorDragOffset(qreal newPosition) +{ + // offset is the distance between the handle and the pointer holding it. Moving the handle, + // or changing what positionAt() returns, makes that distance stale: measure it again, or + // the next handleMove() would put the handle back where it was (QTBUG-130890). + if (pressed) + offset = positionAt(dragPoint) - newPosition; +} + /*! \qmlproperty real QtQuick.Controls::ScrollBar::stepSize diff --git a/src/quicktemplates/qquickscrollbar_p_p.h b/src/quicktemplates/qquickscrollbar_p_p.h index 17e35f3737..5c2f63050f 100644 --- a/src/quicktemplates/qquickscrollbar_p_p.h +++ b/src/quicktemplates/qquickscrollbar_p_p.h @@ -47,6 +47,9 @@ public: qreal logicalPosition(qreal position) const; void setPosition(qreal position, bool notifyVisualChange = true); + void setPositionFromDrag(qreal position, const QPointF &point); + void updatePosition(qreal position, bool notifyVisualChange); + void reanchorDragOffset(qreal position); qreal snapPosition(qreal position) const; qreal positionAt(const QPointF &point) const; void setInteractive(bool interactive); @@ -71,6 +74,7 @@ public: qreal stepSize = 0; qreal offset = 0; qreal minimumSize = 0; + QPointF dragPoint; bool active = false; bool pressed = false; bool moving = false; diff --git a/tests/auto/quickcontrols/controls/data/tst_scrollbar.qml b/tests/auto/quickcontrols/controls/data/tst_scrollbar.qml index f3bd452a69..a663728453 100644 --- a/tests/auto/quickcontrols/controls/data/tst_scrollbar.qml +++ b/tests/auto/quickcontrols/controls/data/tst_scrollbar.qml @@ -53,6 +53,49 @@ TestCase { } } + component SalmonRect: Rectangle { color: "salmon" } + component SteelBlueRect: Rectangle { color: "steelblue" } + + // The reproducer from QTBUG-130890. + Component { + id: variableSizeDelegatesListView + ListView { + id: objectModelList + objectName: "variableSizeDelegatesListView" + width: 400 + height: 400 + ScrollBar.vertical: ScrollBar { + // The iOS style leaves the implicit width at 0, so give the bar a width of + // its own, or there is nothing to press. + width: 20 + } + model: ObjectModel { + SalmonRect { width: objectModelList.width; height: 400 } + SteelBlueRect { width: objectModelList.width; height: 100 } + SalmonRect { width: objectModelList.width; height: 600 } + SteelBlueRect { width: objectModelList.width; height: 40 } + SalmonRect { width: objectModelList.width; height: 50 } + SteelBlueRect { width: objectModelList.width; height: 10 } + SalmonRect { width: objectModelList.width; height: 50 } + SteelBlueRect { width: objectModelList.width; height: 30 } + SalmonRect { width: objectModelList.width; height: 50 } + SteelBlueRect { width: objectModelList.width; height: 100 } + SalmonRect { width: objectModelList.width; height: 200 } + SteelBlueRect { width: objectModelList.width; height: 30 } + SalmonRect { width: objectModelList.width; height: 300 } + SteelBlueRect { width: objectModelList.width; height: 30 } + SalmonRect { width: objectModelList.width; height: 600 } + SteelBlueRect { width: objectModelList.width; height: 40 } + SalmonRect { width: objectModelList.width; height: 50 } + SteelBlueRect { width: objectModelList.width; height: 10 } + SalmonRect { width: objectModelList.width; height: 50 } + SteelBlueRect { width: objectModelList.width; height: 30 } + SalmonRect { width: objectModelList.width; height: 50 } + SteelBlueRect { width: objectModelList.width; height: 100 } + } + } + } + function init() { failOnWarning(/.?/) } @@ -1004,4 +1047,67 @@ TestCase { compare(spy.count, 1) compare(control.visualSize, 0.5) } + + function test_dragHandleWithVariableSizeDelegates_data() { + return [ + { tag: "TopToBottom", verticalLayoutDirection: ListView.TopToBottom }, + { tag: "BottomToTop", verticalLayoutDirection: ListView.BottomToTop }, + ] + } + + // Dragged slowly, one pixel at a time, the way it was reported: the content must follow the + // pointer even though the drag keeps changing the contentHeight estimate, and with it the + // mapping from position to contentY. The handle itself may move and resize as the estimate + // is refined (QTBUG-130890). + function test_dragHandleWithVariableSizeDelegates(data) { + let listView = createTemporaryObject(variableSizeDelegatesListView, testCase, + { verticalLayoutDirection: data.verticalLayoutDirection }) + verify(listView) + let control = listView.ScrollBar.vertical + verify(control) + waitForRendering(listView) + + // A re-estimation can legitimately nudge contentY by a fraction of a pixel. + const tolerance = 1.0 + const steps = 80 + const x = control.width / 2 + // Drag away from whichever end the view starts at, so that there is room to move. + const direction = control.position < 0.5 ? 1 : -1 + const yStart = control.topPadding + control.position * control.availableHeight + 2 + const contentHeightAtPress = listView.contentHeight + + mousePress(control, x, yStart) + verify(control.pressed) + + let contentHeightChanged = false + let previousContentY = listView.contentY + + function verifyContentYFollowsDrag(i, sign) { + if (listView.contentHeight !== contentHeightAtPress) + contentHeightChanged = true + verify((listView.contentY - previousContentY) * sign >= -tolerance, + "step " + i + ": expected contentY to follow the drag, but it went from " + + previousContentY + " to " + listView.contentY + + " while contentHeight is " + listView.contentHeight + + " and originY is " + listView.originY) + previousContentY = listView.contentY + } + + for (let i = 1; i <= steps; ++i) { + mouseMove(control, x, yStart + direction * i) + verifyContentYFollowsDrag(i, direction) + } + + verify(contentHeightChanged, + "the test is inconclusive: contentHeight was never re-estimated during the drag") + + // Reverse the drag without releasing. + for (let i = steps - 1; i >= 0; --i) { + mouseMove(control, x, yStart + direction * i) + verifyContentYFollowsDrag(i, -direction) + } + + mouseRelease(control, x, yStart) + verify(!control.pressed) + } } -- cgit v1.2.3