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 { component WrappedLoader: Loader {
id: comp
required property bool shouldBeActive required property bool shouldBeActive
opacity: shouldBeActive ? 1 : 0 active: false
active: opacity > 0 opacity: 0
Behavior on opacity { // Makes the loader load on the same frame shouldBeActive becomes true, which ensures size is set
Anim {} 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 implicitHeight: contentHeight
spacing: Appearance.spacing.small spacing: Appearance.spacing.small
readyDelay: 1
cacheBuffer: 400 cacheBuffer: 400
asynchronous: true asynchronous: true

View file

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

View file

@ -40,6 +40,17 @@ void LazyListViewAttached::setVisibleHeight(qreal height) {
emit visibleHeightChanged(); 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 { bool LazyListViewAttached::adding() const {
return m_adding; return m_adding;
} }
@ -268,7 +279,14 @@ qreal LazyListView::delegateVisibleHeight(QQuickItem* item) {
return item->implicitHeight(); 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 { int LazyListView::removeDuration() const {
return m_removeDuration; return m_removeDuration;
@ -281,6 +299,17 @@ void LazyListView::setRemoveDuration(int duration) {
emit removeDurationChanged(); 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 --- // --- State ---
int LazyListView::count() const { int LazyListView::count() const {
@ -315,12 +344,32 @@ void LazyListView::updatePolish() {
if (!m_componentComplete || !m_model || !m_delegate) if (!m_componentComplete || !m_model || !m_delegate)
return; 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(); relayout();
syncDelegates(); syncDelegates();
// Position delegates — QML Behavior on y handles the animation // Position delegates — QML Behavior on y handles the animation
for (auto& entry : m_delegates) { for (auto& entry : m_delegates) {
if (!entry.item || entry.pendingRemoval) if (!entry.item || entry.pendingRemoval || entry.pendingInsert)
continue; continue;
const int idx = entry.modelIndex; const int idx = entry.modelIndex;
@ -523,32 +572,15 @@ void LazyListView::syncDelegates() {
// Batch create // Batch create
const int createBudget = m_asynchronous ? ASYNC_BATCH_CREATE : static_cast<int>(toCreate.size()); const int createBudget = m_asynchronous ? ASYNC_BATCH_CREATE : static_cast<int>(toCreate.size());
int created = 0; int created = 0;
bool layoutChanged = false;
for (int i : toCreate) { for (int i : toCreate) {
if (created >= createBudget) if (created >= createBudget)
break; break;
auto entry = createDelegate(i); auto entry = createDelegate(i);
if (entry.item) { if (entry.item) {
const qreal h = delegateHeight(entry.item); // Height tracking and viewport compensation are deferred
if (!m_layout[i].heightKnown || !qFuzzyCompare(m_layout[i].height + 1.0, h + 1.0)) { // until the delegate signals ready via readyChanged.
const qreal oldLayoutH = m_layout[i].heightKnown ? m_layout[i].height : effectiveEstimatedHeight(); entry.pendingInsert = true;
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;
}
entry.item->setY(m_layout[i].targetY - m_contentY); entry.item->setY(m_layout[i].targetY - m_contentY);
m_itemToIndex.insert(entry.item, i); m_itemToIndex.insert(entry.item, i);
m_delegates.insert(i, std::move(entry)); m_delegates.insert(i, std::move(entry));
@ -556,12 +588,10 @@ void LazyListView::syncDelegates() {
} }
} }
if (layoutChanged) // Pending inserts need to become visible on the next frame, and
relayout(); // async mode may have remaining create/destroy work.
if (created > 0 || (m_asynchronous && (destroyed < static_cast<int>(toRemove.size()) ||
// If async and there's remaining work, schedule another pass created < static_cast<int>(toCreate.size()))))
if (m_asynchronous &&
(destroyed < static_cast<int>(toRemove.size()) || created < static_cast<int>(toCreate.size())))
polish(); polish();
} }
@ -616,7 +646,7 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) {
entry.item->setWidth(width()); entry.item->setWidth(width());
// Set adding = true before completeCreate so bindings see it during initial evaluation. // 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 = auto* addingAttached =
qobject_cast<LazyListViewAttached*>(qmlAttachedPropertiesObject<LazyListView>(entry.item, true)); qobject_cast<LazyListViewAttached*>(qmlAttachedPropertiesObject<LazyListView>(entry.item, true));
if (addingAttached) if (addingAttached)
@ -624,11 +654,14 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) {
m_delegate->completeCreate(); m_delegate->completeCreate();
if (addingAttached) // Keep adding=true and hide — flushed on the next frame in updatePolish
addingAttached->setAdding(false); 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] { auto onHeightChanged = [this, item = entry.item] {
if (!isDelegateReady(item))
return;
auto indexIt = m_itemToIndex.find(item); auto indexIt = m_itemToIndex.find(item);
if (indexIt == m_itemToIndex.end()) if (indexIt == m_itemToIndex.end())
return; return;
@ -678,6 +711,33 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) {
connect(attached, &LazyListViewAttached::visibleHeightChanged, this, [this] { connect(attached, &LazyListViewAttached::visibleHeightChanged, this, [this] {
polish(); 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; return entry;
@ -818,6 +878,12 @@ void LazyListView::onRowsAboutToBeRemoved(const QModelIndex& parent, int first,
m_itemToIndex.remove(entry.item); m_itemToIndex.remove(entry.item);
entry.pendingRemoval = true; entry.pendingRemoval = true;
// Never made visible — skip remove animation
if (entry.pendingInsert) {
destroyDelegate(entry);
continue;
}
if (m_removeDuration > 0 && entry.item) { if (m_removeDuration > 0 && entry.item) {
auto* attached = auto* attached =
qobject_cast<LazyListViewAttached*>(qmlAttachedPropertiesObject<LazyListView>(entry.item, false)); 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 preferredHeight READ preferredHeight WRITE setPreferredHeight NOTIFY preferredHeightChanged)
Q_PROPERTY(qreal visibleHeight READ visibleHeight WRITE setVisibleHeight NOTIFY visibleHeightChanged) 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 adding READ adding NOTIFY addingChanged)
Q_PROPERTY(bool removing READ removing NOTIFY removingChanged) Q_PROPERTY(bool removing READ removing NOTIFY removingChanged)
Q_PROPERTY(bool trackViewport READ trackViewport WRITE setTrackViewport NOTIFY trackViewportChanged) Q_PROPERTY(bool trackViewport READ trackViewport WRITE setTrackViewport NOTIFY trackViewportChanged)
@ -29,6 +30,9 @@ public:
[[nodiscard]] qreal visibleHeight() const; [[nodiscard]] qreal visibleHeight() const;
void setVisibleHeight(qreal height); void setVisibleHeight(qreal height);
[[nodiscard]] bool ready() const;
void setReady(bool ready);
[[nodiscard]] bool adding() const; [[nodiscard]] bool adding() const;
void setAdding(bool adding); void setAdding(bool adding);
@ -41,6 +45,7 @@ public:
signals: signals:
void preferredHeightChanged(); void preferredHeightChanged();
void visibleHeightChanged(); void visibleHeightChanged();
void readyChanged();
void addingChanged(); void addingChanged();
void removingChanged(); void removingChanged();
void trackViewportChanged(); void trackViewportChanged();
@ -48,6 +53,7 @@ signals:
private: private:
qreal m_preferredHeight = -1; qreal m_preferredHeight = -1;
qreal m_visibleHeight = -1; qreal m_visibleHeight = -1;
bool m_ready = false;
bool m_adding = false; bool m_adding = false;
bool m_removing = false; bool m_removing = false;
bool m_trackViewport = false; bool m_trackViewport = false;
@ -79,8 +85,9 @@ class LazyListView : public QQuickItem {
// Async // Async
Q_PROPERTY(bool asynchronous READ asynchronous WRITE setAsynchronous NOTIFY asynchronousChanged) 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 removeDuration READ removeDuration WRITE setRemoveDuration NOTIFY removeDurationChanged)
Q_PROPERTY(int readyDelay READ readyDelay WRITE setReadyDelay NOTIFY readyDelayChanged)
// State // State
Q_PROPERTY(int count READ count NOTIFY countChanged) Q_PROPERTY(int count READ count NOTIFY countChanged)
@ -126,10 +133,13 @@ public:
[[nodiscard]] bool asynchronous() const; [[nodiscard]] bool asynchronous() const;
void setAsynchronous(bool async); void setAsynchronous(bool async);
// Remove Animation // Animation Durations
[[nodiscard]] int removeDuration() const; [[nodiscard]] int removeDuration() const;
void setRemoveDuration(int duration); void setRemoveDuration(int duration);
[[nodiscard]] int readyDelay() const;
void setReadyDelay(int delay);
// State // State
[[nodiscard]] int count() const; [[nodiscard]] int count() const;
signals: signals:
@ -145,6 +155,7 @@ signals:
void estimatedHeightChanged(); void estimatedHeightChanged();
void asynchronousChanged(); void asynchronousChanged();
void removeDurationChanged(); void removeDurationChanged();
void readyDelayChanged();
void countChanged(); void countChanged();
void viewportAdjustNeeded(qreal delta); void viewportAdjustNeeded(qreal delta);
@ -164,6 +175,7 @@ private:
int modelIndex = -1; int modelIndex = -1;
QQuickItem* item = nullptr; QQuickItem* item = nullptr;
bool pendingRemoval = false; bool pendingRemoval = false;
bool pendingInsert = false;
}; };
// Layout // Layout
@ -173,6 +185,7 @@ private:
[[nodiscard]] qreal effectiveEstimatedHeight() const; [[nodiscard]] qreal effectiveEstimatedHeight() const;
[[nodiscard]] static qreal delegateHeight(QQuickItem* item); [[nodiscard]] static qreal delegateHeight(QQuickItem* item);
[[nodiscard]] static qreal delegateVisibleHeight(QQuickItem* item); [[nodiscard]] static qreal delegateVisibleHeight(QQuickItem* item);
[[nodiscard]] static bool isDelegateReady(QQuickItem* item);
void trackHeight(qreal height); void trackHeight(qreal height);
void untrackHeight(qreal height); void untrackHeight(qreal height);
@ -212,6 +225,7 @@ private:
bool m_asynchronous = false; bool m_asynchronous = false;
int m_removeDuration = 300; int m_removeDuration = 300;
int m_readyDelay = 0;
QVector<ItemRecord> m_layout; QVector<ItemRecord> m_layout;
QHash<int, DelegateEntry> m_delegates; QHash<int, DelegateEntry> m_delegates;