fix: scroll mark-as-read — three bugs found from round 3 logs
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s

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) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-04 00:19:53 -05:00
parent f0717ce347
commit cb7907ef33

View File

@@ -7,9 +7,10 @@ struct EntryListView: View {
// Scroll-based read tracking all driven from per-row GeometryReader // Scroll-based read tracking all driven from per-row GeometryReader
@State private var lastKnownMinY: [Int: CGFloat] = [:] // entry.id last minY @State private var lastKnownMinY: [Int: CGFloat] = [:] // entry.id last minY
@State private var trackingActive = false @State private var trackingActive = false
@State private var scrollingDown = false
@State private var cumulativeDown: CGFloat = 0 @State private var cumulativeDown: CGFloat = 0
@State private var cumulativeUp: CGFloat = 0
@State private var markedByScroll: Set<Int> = [] @State private var markedByScroll: Set<Int> = []
@State private var wasVisible: Set<Int> = []
// Viewport height measured once from screen bounds. // Viewport height measured once from screen bounds.
// Safe because this view only appears on iPhone in portrait/landscape. // Safe because this view only appears on iPhone in portrait/landscape.
@@ -44,7 +45,9 @@ struct EntryListView: View {
.onAppear { .onAppear {
// Reset on every appear (including back from article) // Reset on every appear (including back from article)
trackingActive = false trackingActive = false
scrollingDown = false
cumulativeDown = 0 cumulativeDown = 0
cumulativeUp = 0
lastKnownMinY.removeAll() lastKnownMinY.removeAll()
} }
.refreshable { .refreshable {
@@ -71,54 +74,33 @@ struct EntryListView: View {
// --- Scroll direction + activation --- // --- Scroll direction + activation ---
let prevMinY = lastKnownMinY[entryId] let prevMinY = lastKnownMinY[entryId]
let isScrollingDown: Bool
if let prev = prevMinY { if let prev = prevMinY {
let delta = prev - newMinY // positive = scrolling down 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 { if delta > 0 {
cumulativeDown += delta cumulativeDown += delta
cumulativeUp = 0 // reset upward counter
if cumulativeDown > activationThreshold { if cumulativeDown > activationThreshold {
trackingActive = true 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 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 --- // --- Mark-as-read ---
if vm.entries.prefix(3).contains(where: { $0.id == entryId }) && frame.maxY < 50 { // LazyVStack destroys views at ~maxY=0, so use maxY<30.
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: ","))")
}
}
guard trackingActive, guard trackingActive,
isScrollingDown, scrollingDown,
!entry.isRead, !entry.isRead,
!markedByScroll.contains(entryId), !markedByScroll.contains(entryId),
wasVisible.contains(entryId), frame.maxY < 30 else { return }
frame.maxY < 0 else { return }
markedByScroll.insert(entryId) markedByScroll.insert(entryId)