fix: move anim logic to qml

Fixes closing in quick succession not updating movement correctly
This commit is contained in:
2 * r + 2 * t 2026-04-03 03:51:33 +11:00
parent 0252be370e
commit 051b8caee6
3 changed files with 101 additions and 99 deletions

View file

@ -72,9 +72,12 @@ LazyListView {
} }
} }
LazyListView.preferredHeight: closed ? 0 : notifInner.implicitHeight LazyListView.preferredHeight: closed ? 0 : notifInner.nonAnimHeight
implicitHeight: notifInner.implicitHeight implicitHeight: notifInner.implicitHeight
opacity: LazyListView.removing || closed || LazyListView.adding ? 0 : 1
scale: LazyListView.removing || closed ? 0.6 : LazyListView.adding ? 0 : 1
hoverEnabled: true hoverEnabled: true
cursorShape: pressed ? Qt.ClosedHandCursor : undefined cursorShape: pressed ? Qt.ClosedHandCursor : undefined
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
@ -114,6 +117,24 @@ LazyListView {
visibilities: root.visibilities visibilities: root.visibilities
} }
Behavior on y {
Anim {
duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
}
}
Behavior on opacity {
Anim {}
}
Behavior on scale {
Anim {
duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
}
}
Behavior on x { Behavior on x {
Anim { Anim {
duration: Appearance.anim.durations.expressiveDefaultSpatial duration: Appearance.anim.durations.expressiveDefaultSpatial

View file

@ -2,6 +2,7 @@
#include <algorithm> #include <algorithm>
#include <qpropertyanimation.h> #include <qpropertyanimation.h>
#include <qtimer.h>
namespace caelestia::components { namespace caelestia::components {
@ -21,12 +22,33 @@ void LazyListViewAttached::setPreferredHeight(qreal height) {
emit preferredHeightChanged(); emit preferredHeightChanged();
} }
bool LazyListViewAttached::adding() const {
return m_adding;
}
void LazyListViewAttached::setAdding(bool adding) {
if (m_adding == adding)
return;
m_adding = adding;
emit addingChanged();
}
bool LazyListViewAttached::removing() const {
return m_removing;
}
void LazyListViewAttached::setRemoving(bool removing) {
if (m_removing == removing)
return;
m_removing = removing;
emit removingChanged();
}
// --- LazyListView --- // --- LazyListView ---
LazyListView::LazyListView(QQuickItem* parent) LazyListView::LazyListView(QQuickItem* parent)
: QQuickItem(parent) { : QQuickItem(parent) {
setFlag(ItemHasContents, false); setFlag(ItemHasContents, false);
setClip(true);
} }
LazyListViewAttached* LazyListView::qmlAttachedProperties(QObject* object) { LazyListViewAttached* LazyListView::qmlAttachedProperties(QObject* object) {
@ -345,50 +367,24 @@ void LazyListView::updatePolish() {
relayout(); relayout();
syncDelegates(); syncDelegates();
// Animate newly created delegates that were pending add animation // Position delegates — QML Behavior on y handles the animation
QSet<int> pendingAdds;
m_pendingAddAnimations.swap(pendingAdds);
for (int idx : std::as_const(pendingAdds)) {
if (m_delegates.contains(idx) && m_addDuration > 0)
startAddAnimation(m_delegates[idx]);
}
// Position delegates, animating displacement if a model change occurred
const bool animate = m_animateDisplacement;
m_animateDisplacement = false;
for (auto& entry : m_delegates) { for (auto& entry : m_delegates) {
if (!entry.item || entry.pendingRemoval || entry.animation) if (!entry.item || entry.pendingRemoval)
continue; continue;
const int idx = entry.modelIndex; const int idx = entry.modelIndex;
if (idx < 0 || idx >= static_cast<int>(m_layout.size())) if (idx < 0 || idx >= static_cast<int>(m_layout.size()))
continue; continue;
const qreal targetY = m_layout[idx].targetY - m_contentY; if (m_layout[idx].heightKnown && qFuzzyIsNull(m_layout[idx].height))
const qreal currentY = entry.item->y(); continue;
if (animate && !qFuzzyCompare(currentY, targetY) && m_moveDuration > 0 // Use setProperty to go through the QML property system,
&& !pendingAdds.contains(idx)) { // which triggers Behaviors (setY bypasses them).
startMoveAnimation(entry, currentY); entry.item->setProperty("y", m_layout[idx].targetY - m_contentY);
} else if (!entry.animation) {
entry.item->setY(targetY);
} }
} }
// Flush delegate pool — any delegates not reclaimed are truly removed
for (auto& pooled : m_delegatePool) {
pooled.pendingRemoval = true;
if (m_removeDuration > 0 && pooled.item) {
startRemoveAnimation(pooled);
m_dyingDelegates.append(std::move(pooled));
} else {
destroyDelegate(pooled);
}
}
m_delegatePool.clear();
}
// --- Layout Engine --- // --- Layout Engine ---
void LazyListView::relayout() { void LazyListView::relayout() {
@ -491,6 +487,8 @@ void LazyListView::syncDelegates() {
m_layout[i].heightKnown = true; m_layout[i].heightKnown = true;
trackHeight(h); trackHeight(h);
} }
// Position immediately so it doesn't flash at y=0
entry.item->setY(m_layout[i].targetY - m_contentY);
m_delegates.insert(i, std::move(entry)); m_delegates.insert(i, std::move(entry));
} }
} }
@ -504,26 +502,8 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) {
if (!m_delegate || !m_model) if (!m_delegate || !m_model)
return entry; return entry;
// Try to reclaim a delegate from the pool (reuse after remove+insert cycle)
const auto roleNames = m_model->roleNames(); const auto roleNames = m_model->roleNames();
const auto role = roleNames.isEmpty() ? Qt::DisplayRole : roleNames.constBegin().key(); const auto role = roleNames.isEmpty() ? Qt::DisplayRole : roleNames.constBegin().key();
const auto targetData = m_model->data(m_model->index(modelIndex, 0), role);
for (auto it = m_delegatePool.begin(); it != m_delegatePool.end(); ++it) {
if (!it->item)
continue;
const auto poolData = it->item->property("modelData");
if (poolData == targetData) {
entry = std::move(*it);
m_delegatePool.erase(it);
entry.modelIndex = modelIndex;
entry.pendingRemoval = false;
updateDelegateData(entry);
entry.item->setParentItem(this);
entry.item->setWidth(width());
return entry;
}
}
// Use the delegate component's creation context so the delegate // Use the delegate component's creation context so the delegate
// can access ids and properties from the scope where it was defined. // can access ids and properties from the scope where it was defined.
@ -572,8 +552,19 @@ LazyListView::DelegateEntry LazyListView::createDelegate(int modelIndex) {
entry.item->setParentItem(this); entry.item->setParentItem(this);
entry.item->setWidth(width()); 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.
auto* addingAttached = qobject_cast<LazyListViewAttached*>(
qmlAttachedPropertiesObject<LazyListView>(entry.item, true));
if (addingAttached)
addingAttached->setAdding(true);
m_delegate->completeCreate(); m_delegate->completeCreate();
if (addingAttached)
addingAttached->setAdding(false);
// Shared height-change handler — captures item pointer instead of index // Shared height-change handler — captures item pointer instead of index
// so it remains valid after model inserts/removes/moves shift indices. // so it remains valid after model inserts/removes/moves shift indices.
auto onHeightChanged = [this, item = entry.item] { auto onHeightChanged = [this, item = entry.item] {
@ -713,10 +704,6 @@ void LazyListView::resetContent() {
destroyDelegate(entry); destroyDelegate(entry);
m_dyingDelegates.clear(); m_dyingDelegates.clear();
for (auto& entry : m_delegatePool)
destroyDelegate(entry);
m_delegatePool.clear();
if (m_activeAnimations != 0) { if (m_activeAnimations != 0) {
m_activeAnimations = 0; m_activeAnimations = 0;
emit settledChanged(); emit settledChanged();
@ -726,7 +713,6 @@ void LazyListView::resetContent() {
m_knownHeightSum = 0; m_knownHeightSum = 0;
m_knownHeightCount = 0; m_knownHeightCount = 0;
m_pendingAddAnimations.clear(); m_pendingAddAnimations.clear();
m_animateDisplacement = false;
// Rebuild layout from model // Rebuild layout from model
m_layout.clear(); m_layout.clear();
@ -766,7 +752,6 @@ void LazyListView::onRowsInserted(const QModelIndex& parent, int first, int last
// Queue add animations and mark displacement // Queue add animations and mark displacement
for (int i = first; i <= last; ++i) for (int i = first; i <= last; ++i)
m_pendingAddAnimations.insert(i); m_pendingAddAnimations.insert(i);
m_animateDisplacement = true;
emit countChanged(); emit countChanged();
polish(); polish();
@ -776,14 +761,36 @@ void LazyListView::onRowsAboutToBeRemoved(const QModelIndex& parent, int first,
if (parent.isValid()) if (parent.isValid())
return; return;
// Pool removed delegates — they may be reused if the model re-inserts the same data
for (int i = first; i <= last; ++i) { for (int i = first; i <= last; ++i) {
if (!m_delegates.contains(i)) if (!m_delegates.contains(i))
continue; continue;
auto entry = m_delegates.take(i); auto entry = m_delegates.take(i);
entry.pendingRemoval = true;
stopAnimation(entry); stopAnimation(entry);
m_delegatePool.append(std::move(entry));
if (m_removeDuration > 0 && entry.item) {
// Signal the delegate via attached property — QML handles the visual transition
auto* attached = qobject_cast<LazyListViewAttached*>(
qmlAttachedPropertiesObject<LazyListView>(entry.item, false));
if (attached)
attached->setRemoving(true);
// Schedule destruction after the remove animation duration
auto* item = entry.item;
QTimer::singleShot(m_removeDuration, this, [this, item] {
for (auto it = m_dyingDelegates.begin(); it != m_dyingDelegates.end(); ++it) {
if (it->item == item) {
destroyDelegate(*it);
m_dyingDelegates.erase(it);
return;
}
}
});
m_dyingDelegates.append(std::move(entry));
} else {
destroyDelegate(entry);
}
} }
} }
@ -814,7 +821,6 @@ void LazyListView::onRowsRemoved(const QModelIndex& parent, int first, int last)
} }
m_delegates = std::move(shifted); m_delegates = std::move(shifted);
m_animateDisplacement = true;
emit countChanged(); emit countChanged();
polish(); polish();
@ -860,7 +866,6 @@ void LazyListView::onRowsMoved(const QModelIndex& parent, int start, int end,
} }
m_delegates = std::move(remapped); m_delegates = std::move(remapped);
m_animateDisplacement = true;
polish(); polish();
} }
@ -999,39 +1004,6 @@ void LazyListView::startRemoveAnimation(DelegateEntry& entry) {
group->start(QAbstractAnimation::DeleteWhenStopped); group->start(QAbstractAnimation::DeleteWhenStopped);
} }
void LazyListView::startMoveAnimation(DelegateEntry& entry, qreal fromY) {
if (!entry.item || m_moveDuration <= 0)
return;
const int idx = entry.modelIndex;
if (idx < 0 || idx >= static_cast<int>(m_layout.size()))
return;
const qreal toY = m_layout[idx].targetY - m_contentY;
if (qFuzzyCompare(fromY, toY))
return;
stopAnimation(entry);
auto* group = new QParallelAnimationGroup(this);
auto* yAnim = new QPropertyAnimation(entry.item, "y");
yAnim->setDuration(m_moveDuration);
yAnim->setEasingCurve(m_moveCurve);
yAnim->setStartValue(fromY);
yAnim->setEndValue(toY);
group->addAnimation(yAnim);
entry.item->setY(fromY);
entry.animation = group;
++m_activeAnimations;
if (m_activeAnimations == 1)
emit settledChanged();
connect(group, &QAbstractAnimation::finished, this, &LazyListView::onAnimationFinished);
group->start(QAbstractAnimation::DeleteWhenStopped);
}
void LazyListView::stopAnimation(DelegateEntry& entry) { void LazyListView::stopAnimation(DelegateEntry& entry) {
if (!entry.animation) if (!entry.animation)
return; return;

View file

@ -18,6 +18,8 @@ class LazyListViewAttached : public QObject {
Q_OBJECT Q_OBJECT
Q_PROPERTY(qreal preferredHeight READ preferredHeight WRITE setPreferredHeight NOTIFY preferredHeightChanged) Q_PROPERTY(qreal preferredHeight READ preferredHeight WRITE setPreferredHeight NOTIFY preferredHeightChanged)
Q_PROPERTY(bool adding READ adding NOTIFY addingChanged)
Q_PROPERTY(bool removing READ removing NOTIFY removingChanged)
public: public:
explicit LazyListViewAttached(QObject* parent = nullptr); explicit LazyListViewAttached(QObject* parent = nullptr);
@ -25,11 +27,21 @@ public:
[[nodiscard]] qreal preferredHeight() const; [[nodiscard]] qreal preferredHeight() const;
void setPreferredHeight(qreal height); void setPreferredHeight(qreal height);
[[nodiscard]] bool adding() const;
void setAdding(bool adding);
[[nodiscard]] bool removing() const;
void setRemoving(bool removing);
signals: signals:
void preferredHeightChanged(); void preferredHeightChanged();
void addingChanged();
void removingChanged();
private: private:
qreal m_preferredHeight = -1; qreal m_preferredHeight = -1;
bool m_adding = false;
bool m_removing = false;
}; };
class LazyListView : public QQuickItem { class LazyListView : public QQuickItem {
@ -220,7 +232,6 @@ private:
// Animation // Animation
void startAddAnimation(DelegateEntry& entry); void startAddAnimation(DelegateEntry& entry);
void startRemoveAnimation(DelegateEntry& entry); void startRemoveAnimation(DelegateEntry& entry);
void startMoveAnimation(DelegateEntry& entry, qreal fromY);
void stopAnimation(DelegateEntry& entry); void stopAnimation(DelegateEntry& entry);
void onAnimationFinished(); void onAnimationFinished();
@ -256,11 +267,9 @@ private:
QVector<ItemRecord> m_layout; QVector<ItemRecord> m_layout;
QHash<int, DelegateEntry> m_delegates; QHash<int, DelegateEntry> m_delegates;
QVector<DelegateEntry> m_dyingDelegates; QVector<DelegateEntry> m_dyingDelegates;
QVector<DelegateEntry> m_delegatePool;
int m_activeAnimations = 0; int m_activeAnimations = 0;
bool m_componentComplete = false; bool m_componentComplete = false;
bool m_animateDisplacement = false;
QSet<int> m_pendingAddAnimations; QSet<int> m_pendingAddAnimations;
QList<QMetaObject::Connection> m_modelConnections; QList<QMetaObject::Connection> m_modelConnections;