fix: scroll mark-as-read — three bugs found from round 3 logs
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:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user