From cb7907ef33341f068a65f248020de36c90fb32a0 Mon Sep 17 00:00:00 2001 From: Yusuf Suleman Date: Sat, 4 Apr 2026 00:19:53 -0500 Subject: [PATCH] =?UTF-8?q?fix:=20scroll=20mark-as-read=20=E2=80=94=20thre?= =?UTF-8?q?e=20bugs=20found=20from=20round=203=20logs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LOG EVIDENCE: 1. notVisible: wasVisible never set for entries already on screen at list load. Removed wasVisible guard — trackingActive (100pt scroll) is sufficient protection. 2. aboveVP: maxY never goes below 0. LazyVStack destroys views at ~maxY=0. Changed threshold from maxY<0 to maxY<30 (nearly off). 3. notDown flickering: per-entry deltas are ~1pt, causing direction to flip between down/not-down on every callback. Made direction sticky: scrollingDown stays true until 30pt of cumulative upward scroll is detected. Prevents jitter from sub-pixel noise. Removed debug logging. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Features/Reader/Views/EntryListView.swift | 50 ++++++------------- 1 file changed, 16 insertions(+), 34 deletions(-) diff --git a/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift b/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift index 58272b9..9ddb412 100644 --- a/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift +++ b/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift @@ -7,9 +7,10 @@ struct EntryListView: View { // Scroll-based read tracking — all driven from per-row GeometryReader @State private var lastKnownMinY: [Int: CGFloat] = [:] // entry.id → last minY @State private var trackingActive = false + @State private var scrollingDown = false @State private var cumulativeDown: CGFloat = 0 + @State private var cumulativeUp: CGFloat = 0 @State private var markedByScroll: Set = [] - @State private var wasVisible: Set = [] // Viewport height — measured once from screen bounds. // Safe because this view only appears on iPhone in portrait/landscape. @@ -44,7 +45,9 @@ struct EntryListView: View { .onAppear { // Reset on every appear (including back from article) trackingActive = false + scrollingDown = false cumulativeDown = 0 + cumulativeUp = 0 lastKnownMinY.removeAll() } .refreshable { @@ -71,54 +74,33 @@ struct EntryListView: View { // --- Scroll direction + activation --- let prevMinY = lastKnownMinY[entryId] - let isScrollingDown: Bool if let prev = prevMinY { let delta = prev - newMinY // positive = scrolling down - // Per-entry deltas are small (~1-2pt per callback). - // Accept anything > 0.5 as genuine downward scroll. - isScrollingDown = delta > 0.5 if delta > 0 { cumulativeDown += delta + cumulativeUp = 0 // reset upward counter if cumulativeDown > activationThreshold { trackingActive = true } + scrollingDown = true + } else if delta < -0.5 { + cumulativeUp += -delta + // Only flip direction after 30pt of upward scroll + // (prevents jitter from tiny floating point noise) + if cumulativeUp > 30 { + scrollingDown = false + } } - } else { - isScrollingDown = false } - // Store AFTER reading previous value lastKnownMinY[entryId] = newMinY - // --- Visibility tracking --- - let visibleTop = max(frame.minY, 0) - let visibleBottom = min(frame.maxY, viewportHeight) - let visibleHeight = max(visibleBottom - visibleTop, 0) - let visibleRatio = visibleHeight / entryHeight - if visibleRatio >= 0.5 { - wasVisible.insert(entryId) - } - // --- Mark-as-read --- - if vm.entries.prefix(3).contains(where: { $0.id == entryId }) && frame.maxY < 50 { - var fails: [String] = [] - if !trackingActive { fails.append("inactive(\(Int(cumulativeDown))/\(Int(activationThreshold)))") } - if !isScrollingDown { fails.append("notDown") } - if entry.isRead { fails.append("alreadyRead") } - if markedByScroll.contains(entryId) { fails.append("alreadyMarked") } - if !wasVisible.contains(entryId) { fails.append("notVisible") } - if frame.maxY >= 0 { fails.append("aboveVP(\(Int(frame.maxY)))") } - if fails.isEmpty { - print("[SCROLL] ✅ WILL MARK id=\(entryId)") - } else { - print("[SCROLL] ❌ id=\(entryId) maxY=\(Int(frame.maxY)) fails=\(fails.joined(separator: ","))") - } - } + // LazyVStack destroys views at ~maxY=0, so use maxY<30. guard trackingActive, - isScrollingDown, + scrollingDown, !entry.isRead, !markedByScroll.contains(entryId), - wasVisible.contains(entryId), - frame.maxY < 0 else { return } + frame.maxY < 30 else { return } markedByScroll.insert(entryId)