From 2efe1a93a686960ded85e4b161b7418810c76fa5 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Fri, 10 Apr 2026 00:19:16 +1000 Subject: [PATCH] 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 --- modules/sidebar/Notif.qml | 46 ++++++- modules/sidebar/NotifDockList.qml | 1 + modules/sidebar/NotifGroupList.qml | 1 + .../src/Caelestia/Components/lazylistview.cpp | 130 +++++++++++++----- .../src/Caelestia/Components/lazylistview.hpp | 18 ++- 5 files changed, 158 insertions(+), 38 deletions(-) diff --git a/modules/sidebar/Notif.qml b/modules/sidebar/Notif.qml index e23460f1..5ccdc97e 100644 --- a/modules/sidebar/Notif.qml +++ b/modules/sidebar/Notif.qml @@ -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" + } + } + } + ] } } diff --git a/modules/sidebar/NotifDockList.qml b/modules/sidebar/NotifDockList.qml index b0e529c8..d2dc4972 100644 --- a/modules/sidebar/NotifDockList.qml +++ b/modules/sidebar/NotifDockList.qml @@ -19,6 +19,7 @@ LazyListView { implicitHeight: contentHeight spacing: Appearance.spacing.small + readyDelay: 1 cacheBuffer: 400 asynchronous: true diff --git a/modules/sidebar/NotifGroupList.qml b/modules/sidebar/NotifGroupList.qml index 78c6ce59..21692587 100644 --- a/modules/sidebar/NotifGroupList.qml +++ b/modules/sidebar/NotifGroupList.qml @@ -25,6 +25,7 @@ LazyListView { spacing: Math.round(Appearance.spacing.small / 2) asynchronous: true + readyDelay: 1 cacheBuffer: 800 removeDuration: Appearance.anim.durations.normal diff --git a/plugin/src/Caelestia/Components/lazylistview.cpp b/plugin/src/Caelestia/Components/lazylistview.cpp index 1df13123..27a8a92d 100644 --- a/plugin/src/Caelestia/Components/lazylistview.cpp +++ b/plugin/src/Caelestia/Components/lazylistview.cpp @@ -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(qmlAttachedPropertiesObject(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(qmlAttachedPropertiesObject(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(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(qmlAttachedPropertiesObject(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(toRemove.size()) || created < static_cast(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(toRemove.size()) || + created < static_cast(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(qmlAttachedPropertiesObject(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(m_layout.size())) + return; + auto* att = qobject_cast(qmlAttachedPropertiesObject(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(qmlAttachedPropertiesObject(entry.item, false)); diff --git a/plugin/src/Caelestia/Components/lazylistview.hpp b/plugin/src/Caelestia/Components/lazylistview.hpp index 0098fb03..feb501c6 100644 --- a/plugin/src/Caelestia/Components/lazylistview.hpp +++ b/plugin/src/Caelestia/Components/lazylistview.hpp @@ -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 m_layout; QHash m_delegates;