fix: scroll mark-as-read crossing event missed by LazyVStack recycling
ROOT CAUSE: The exact crossing condition (oldMaxY >= 0 && newMaxY < 0) required onChange to fire at the exact moment maxY crosses zero. But LazyVStack recycles views when they scroll off-screen, destroying the GeometryReader before the crossing event is delivered. The entry goes from maxY=15 to being recycled — onChange never sees maxY go negative. FIX: Replace exact crossing with position check (newMaxY < 0). The entry just needs to be fully above the viewport. The other 5 guards prevent false positives: 1. trackingActive (scrolled past threshold) 2. isScrollingDown 3. !entry.isRead 4. !markedByScroll (dedup) 5. wasVisible (was >=50% visible) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -131,21 +131,24 @@ struct EntryListView: View {
|
|||||||
wasVisible.insert(entry.id)
|
wasVisible.insert(entry.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: frame.maxY) { oldMaxY, newMaxY in
|
.onChange(of: frame.maxY) { _, newMaxY in
|
||||||
// Mark as read when entry scrolls above viewport.
|
// Mark as read when entry is above viewport.
|
||||||
// All 6 conditions must be true:
|
// 5 conditions must be true:
|
||||||
// 1. Tracking active (scrolled past dynamic threshold)
|
// 1. Tracking active (scrolled past dynamic threshold)
|
||||||
// 2. Scrolling downward
|
// 2. Scrolling downward
|
||||||
// 3. Entry is unread
|
// 3. Entry is unread
|
||||||
// 4. Not already marked by scroll
|
// 4. Not already marked by scroll
|
||||||
// 5. Was >=50% visible at some point (genuinely seen)
|
// 5. Was >=50% visible at some point (genuinely seen)
|
||||||
// 6. Bottom edge just crossed above viewport
|
//
|
||||||
|
// We check newMaxY < 0 (entry fully above viewport)
|
||||||
|
// instead of requiring an exact crossing event, because
|
||||||
|
// LazyVStack can recycle the view between onChange calls,
|
||||||
|
// causing the 0-boundary crossing to never be delivered.
|
||||||
guard trackingActive,
|
guard trackingActive,
|
||||||
isScrollingDown,
|
isScrollingDown,
|
||||||
!entry.isRead,
|
!entry.isRead,
|
||||||
!markedByScroll.contains(entry.id),
|
!markedByScroll.contains(entry.id),
|
||||||
wasVisible.contains(entry.id),
|
wasVisible.contains(entry.id),
|
||||||
oldMaxY >= 0,
|
|
||||||
newMaxY < 0 else { return }
|
newMaxY < 0 else { return }
|
||||||
|
|
||||||
markedByScroll.insert(entry.id)
|
markedByScroll.insert(entry.id)
|
||||||
|
|||||||
Reference in New Issue
Block a user