fix: layout delegates after configurable delay

Fixes glitches due to delegate sizes changing on creation
Also use the loader state pattern to ensure size is set on same frame as
active
This commit is contained in:
2 * r + 2 * t 2026-04-10 00:19:16 +10:00
parent e6c1248bef
commit 2efe1a93a6
5 changed files with 158 additions and 38 deletions

View file

@ -155,13 +155,51 @@ StyledRect {
}
component WrappedLoader: Loader {
id: comp
required property bool shouldBeActive
opacity: shouldBeActive ? 1 : 0
active: opacity > 0
active: false
opacity: 0
Behavior on opacity {
Anim {}
// Makes the loader load on the same frame shouldBeActive becomes true, which ensures size is set
states: State {
name: "active"
when: comp.shouldBeActive
PropertyChanges {
comp.opacity: 1
comp.active: true
}
}
transitions: [
Transition {
from: ""
to: "active"
SequentialAnimation {
PropertyAction {
property: "active"
}
Anim {
property: "opacity"
}
}
},
Transition {
from: "active"
to: ""
SequentialAnimation {
Anim {
property: "opacity"
}
PropertyAction {
property: "active"
}
}
}
]
}
}

View file

@ -19,6 +19,7 @@ LazyListView {
implicitHeight: contentHeight
spacing: Appearance.spacing.small
readyDelay: 1
cacheBuffer: 400
asynchronous: true

View file

@ -25,6 +25,7 @@ LazyListView {
spacing: Math.round(Appearance.spacing.small / 2)
asynchronous: true
readyDelay: 1
cacheBuffer: 800
removeDuration: Appearance.anim.durations.normal

View file

@ -40,6 +40,17 @@ void LazyListViewAttached::setVisibleHeight(qreal height) {
emit visibleHeightChanged();
}
bool LazyListViewAttached::ready() const {
return m_ready;
}
void LazyListViewAttached::setReady(bool ready) {
if (m_ready == ready)
return;
m_ready = ready;
emit readyChanged();
}
bool LazyListViewAttached::adding() const {
return m_adding;
}
@ -268,7 +279,14 @@ qreal LazyListView::delegateVisibleHeight(QQuickItem* item) {
return item->implicitHeight();
}
// --- Remove Animation ---
bool LazyListView::isDelegateReady(QQuickItem* item) {
if (!item)
return false;
auto* att = qobject_cast<LazyListViewAttached*>(qmlAttachedPropertiesObject<LazyListView>(item, false));
return !att || att->ready();
}
// --- Animation Durations ---
int LazyListView::removeDuration() const {
return m_removeDuration;
@ -281,6 +299,17 @@ void LazyListView::setRemoveDuration(int duration) {
emit removeDurationChanged();
}
int LazyListView::readyDelay() const {
return m_readyDelay;
}
void LazyListView::setReadyDelay(int delay) {
if (m_readyDelay == delay)
return;
m_readyDelay = delay;
emit readyDelayChanged();
}
// --- State ---
int LazyListView::count() const {
@ -315,12 +344,32 @@ void LazyListView::updatePolish() {
if (!m_componentComplete || !m_model || !m_delegate)
return;
// Flush pending inserts from the previous frame — make items visible
// and clear the adding flag so enter animations begin.
for (auto& entry : m_delegates) {
if (!entry.pendingInsert || !entry.item)
continue;
entry.pendingInsert = false;
entry.item->setVisible(true);
auto* att = qobject_cast<LazyListViewAttached*>(qmlAttachedPropertiesObject<LazyListView>(entry.item, false));
if (att) {
att->setAdding(false);
if (m_readyDelay > 0) {
QTimer::singleShot(m_readyDelay, att, [att] {
att->setReady(true);
});
} else {
att->setReady(true);
}
}
}
relayout();
syncDelegates();
// Position delegates — QML Behavior on y handles the animation
for (auto& entry : m_delegates) {
if (!entry.item || entry.pendingRemoval)
if (!entry.item || entry.pendingRemoval || entry.pendingInsert)
continue;
const int idx = entry.modelIndex;
@ -523,32 +572,15 @@ void LazyListView::syncDelegates() {
// Batch create
const int createBudget = m_asynchronous ? ASYNC_BATCH_CREATE : static_cast<int>(toCreate.size());
int created = 0;
bool layoutChanged = false;
for (int i : toCreate) {
if (created >= createBudget)
break;
auto entry = createDelegate(i);
if (entry.item) {
const qreal h = delegateHeight(entry.item);
if (!m_layout[i].heightKnown || !qFuzzyCompare(m_layout[i].height + 1.0, h + 1.0)) {
const qreal oldLayoutH = m_layout[i].heightKnown ? m_layout[i].height : effectiveEstimatedHeight();
if (m_layout[i].heightKnown)
untrackHeight(m_layout[i].height);
m_layout[i].height = h;
m_layout[i].heightKnown = true;
trackHeight(h);
// Compensate if tracked item materializes above viewport
auto* att =
qobject_cast<LazyListViewAttached*>(qmlAttachedPropertiesObject<LazyListView>(entry.item, false));
if (att && att->trackViewport()) {
const qreal vpTop = m_useCustomViewport ? m_viewport.y() : m_contentY;
if (m_layout[i].targetY < vpTop)
emit viewportAdjustNeeded(h - oldLayoutH);
}
layoutChanged = true;
}
// Height tracking and viewport compensation are deferred
// until the delegate signals ready via readyChanged.
entry.pendingInsert = true;
entry.item->setY(m_layout[i].targetY - m_contentY);
m_itemToIndex.insert(entry.item, i);
m_delegates.insert(i, std::move(entry));
@ -556,12 +588,10 @@ void LazyListView::syncDelegates() {
}
}
if (layoutChanged)
relayout();
// If async and there's remaining work, schedule another pass
if (m_asynchronous &&
(destroyed < static_cast<int>(toRemove.size()) || created < static_cast<int>(toCreate.size())))
// Pending inserts need to become visible on the next frame, and
// async mode may have remaining create/destroy work.
if (created > 0 || (m_asynchronous && (destroyed < static_cast<int>(toRemove.size()) ||
created < static_cast<int>(toCreate.size()))))
polish();
}
@ -616,7 +646,7 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) {
entry.item->setWidth(width());
// Set adding = true before completeCreate so bindings see it during initial evaluation.
// Cleared after creation so the transition from true→false triggers QML Behaviors.
// Cleared on the next frame in updatePolish when the item becomes visible.
auto* addingAttached =
qobject_cast<LazyListViewAttached*>(qmlAttachedPropertiesObject<LazyListView>(entry.item, true));
if (addingAttached)
@ -624,11 +654,14 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) {
m_delegate->completeCreate();
if (addingAttached)
addingAttached->setAdding(false);
// Keep adding=true and hide — flushed on the next frame in updatePolish
entry.item->setVisible(false);
// Height-change handler — uses m_itemToIndex for O(1) lookup
// Height-change handler — uses m_itemToIndex for O(1) lookup.
// Ignored while the delegate is not yet ready.
auto onHeightChanged = [this, item = entry.item] {
if (!isDelegateReady(item))
return;
auto indexIt = m_itemToIndex.find(item);
if (indexIt == m_itemToIndex.end())
return;
@ -678,6 +711,33 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) {
connect(attached, &LazyListViewAttached::visibleHeightChanged, this, [this] {
polish();
});
connect(attached, &LazyListViewAttached::readyChanged, this, [this, item = entry.item] {
auto indexIt = m_itemToIndex.find(item);
if (indexIt == m_itemToIndex.end())
return;
const int idx = indexIt.value();
if (idx >= static_cast<int>(m_layout.size()))
return;
auto* att = qobject_cast<LazyListViewAttached*>(qmlAttachedPropertiesObject<LazyListView>(item, false));
if (!att || !att->ready())
return;
const qreal h = delegateHeight(item);
const qreal oldLayoutH = m_layout[idx].heightKnown ? m_layout[idx].height : effectiveEstimatedHeight();
if (m_layout[idx].heightKnown)
untrackHeight(m_layout[idx].height);
m_layout[idx].height = h;
m_layout[idx].heightKnown = true;
trackHeight(h);
if (att->trackViewport() && !qFuzzyCompare(h + 1.0, oldLayoutH + 1.0)) {
const qreal vpTop = m_useCustomViewport ? m_viewport.y() : m_contentY;
if (m_layout[idx].targetY < vpTop)
emit viewportAdjustNeeded(h - oldLayoutH);
}
polish();
});
}
return entry;
@ -818,6 +878,12 @@ void LazyListView::onRowsAboutToBeRemoved(const QModelIndex& parent, int first,
m_itemToIndex.remove(entry.item);
entry.pendingRemoval = true;
// Never made visible — skip remove animation
if (entry.pendingInsert) {
destroyDelegate(entry);
continue;
}
if (m_removeDuration > 0 && entry.item) {
auto* attached =
qobject_cast<LazyListViewAttached*>(qmlAttachedPropertiesObject<LazyListView>(entry.item, false));

View file

@ -16,6 +16,7 @@ class LazyListViewAttached : public QObject {
Q_PROPERTY(qreal preferredHeight READ preferredHeight WRITE setPreferredHeight NOTIFY preferredHeightChanged)
Q_PROPERTY(qreal visibleHeight READ visibleHeight WRITE setVisibleHeight NOTIFY visibleHeightChanged)
Q_PROPERTY(bool ready READ ready NOTIFY readyChanged)
Q_PROPERTY(bool adding READ adding NOTIFY addingChanged)
Q_PROPERTY(bool removing READ removing NOTIFY removingChanged)
Q_PROPERTY(bool trackViewport READ trackViewport WRITE setTrackViewport NOTIFY trackViewportChanged)
@ -29,6 +30,9 @@ public:
[[nodiscard]] qreal visibleHeight() const;
void setVisibleHeight(qreal height);
[[nodiscard]] bool ready() const;
void setReady(bool ready);
[[nodiscard]] bool adding() const;
void setAdding(bool adding);
@ -41,6 +45,7 @@ public:
signals:
void preferredHeightChanged();
void visibleHeightChanged();
void readyChanged();
void addingChanged();
void removingChanged();
void trackViewportChanged();
@ -48,6 +53,7 @@ signals:
private:
qreal m_preferredHeight = -1;
qreal m_visibleHeight = -1;
bool m_ready = false;
bool m_adding = false;
bool m_removing = false;
bool m_trackViewport = false;
@ -79,8 +85,9 @@ class LazyListView : public QQuickItem {
// Async
Q_PROPERTY(bool asynchronous READ asynchronous WRITE setAsynchronous NOTIFY asynchronousChanged)
// Remove Animation
// Animation Durations
Q_PROPERTY(int removeDuration READ removeDuration WRITE setRemoveDuration NOTIFY removeDurationChanged)
Q_PROPERTY(int readyDelay READ readyDelay WRITE setReadyDelay NOTIFY readyDelayChanged)
// State
Q_PROPERTY(int count READ count NOTIFY countChanged)
@ -126,10 +133,13 @@ public:
[[nodiscard]] bool asynchronous() const;
void setAsynchronous(bool async);
// Remove Animation
// Animation Durations
[[nodiscard]] int removeDuration() const;
void setRemoveDuration(int duration);
[[nodiscard]] int readyDelay() const;
void setReadyDelay(int delay);
// State
[[nodiscard]] int count() const;
signals:
@ -145,6 +155,7 @@ signals:
void estimatedHeightChanged();
void asynchronousChanged();
void removeDurationChanged();
void readyDelayChanged();
void countChanged();
void viewportAdjustNeeded(qreal delta);
@ -164,6 +175,7 @@ private:
int modelIndex = -1;
QQuickItem* item = nullptr;
bool pendingRemoval = false;
bool pendingInsert = false;
};
// Layout
@ -173,6 +185,7 @@ private:
[[nodiscard]] qreal effectiveEstimatedHeight() const;
[[nodiscard]] static qreal delegateHeight(QQuickItem* item);
[[nodiscard]] static qreal delegateVisibleHeight(QQuickItem* item);
[[nodiscard]] static bool isDelegateReady(QQuickItem* item);
void trackHeight(qreal height);
void untrackHeight(qreal height);
@ -212,6 +225,7 @@ private:
bool m_asynchronous = false;
int m_removeDuration = 300;
int m_readyDelay = 0;
QVector<ItemRecord> m_layout;
QHash<int, DelegateEntry> m_delegates;