From cd642556329d6fc41404913c431f0fbeed9c3860 Mon Sep 17 00:00:00 2001 From: Yusuf Suleman Date: Fri, 3 Apr 2026 23:58:31 -0500 Subject: [PATCH] fix: scroll mark-as-read crossing event missed by LazyVStack recycling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Features/Reader/Views/EntryListView.swift | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift b/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift index 008af4e..b314e3e 100644 --- a/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift +++ b/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift @@ -131,21 +131,24 @@ struct EntryListView: View { wasVisible.insert(entry.id) } } - .onChange(of: frame.maxY) { oldMaxY, newMaxY in - // Mark as read when entry scrolls above viewport. - // All 6 conditions must be true: + .onChange(of: frame.maxY) { _, newMaxY in + // Mark as read when entry is above viewport. + // 5 conditions must be true: // 1. Tracking active (scrolled past dynamic threshold) // 2. Scrolling downward // 3. Entry is unread // 4. Not already marked by scroll // 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, isScrollingDown, !entry.isRead, !markedByScroll.contains(entry.id), wasVisible.contains(entry.id), - oldMaxY >= 0, newMaxY < 0 else { return } markedByScroll.insert(entry.id)