refine: scroll mark-as-read with visibility, dynamic threshold, stable deltas
All checks were successful
Security Checks / dependency-audit (push) Successful in 14s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s

1. Visibility requirement (new condition 5):
   - Tracks max visible ratio per entry via GeometryReader
   - Entry must have been >=50% visible at some point to qualify
   - Prevents marking entries that were never genuinely seen
   - Uses wasVisible set, populated by onChange(of: minY)

2. Dynamic activation threshold:
   - max(100pt, 20% of viewport height)
   - Taller screens (iPad) require proportionally more scroll
   - Measured via background GeometryReader on ScrollView

3. Stabilized scroll direction:
   - Ignores micro deltas <2pt (was 1pt)
   - Filters layout noise, rubber-banding, and momentum artifacts

Existing protections preserved:
   - trackingActive reset on onAppear (navigation protection)
   - downward-only marking
   - crossing detection (oldMaxY >= 0, newMaxY < 0)
   - markedByScroll dedup set

6 conditions must ALL be true to mark an entry:
   1. trackingActive (scrolled past threshold)
   2. isScrollingDown
   3. !entry.isRead
   4. !markedByScroll.contains(id)
   5. wasVisible.contains(id) — was >=50% visible
   6. bottom edge crossed above viewport

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-03 23:18:53 -05:00
parent 532a071715
commit 1f32e5436e

View File

@@ -21,10 +21,8 @@ struct EntryListView: View {
@State private var trackingActive = false
@State private var cumulativeDown: CGFloat = 0
@State private var markedByScroll: Set<Int> = []
// Require 100pt of genuine downward scroll before tracking activates.
// Prevents mass-marking when returning from article navigation.
private let activationThreshold: CGFloat = 100
@State private var wasVisible: Set<Int> = [] // entries that were >=50% visible
@State private var viewportHeight: CGFloat = 800 // measured from geometry
var body: some View {
if vm.isLoading && vm.entries.isEmpty {
@@ -57,12 +55,18 @@ struct EntryListView: View {
}
}
.coordinateSpace(name: "readerScroll")
.background(
// Measure viewport height once
GeometryReader { geo in
Color.clear.onAppear { viewportHeight = geo.size.height }
}
)
.onPreferenceChange(ScrollOffsetKey.self) { value in
handleScrollOffset(-value)
}
.onAppear {
// Reset tracking on every appear (including back from article).
// User must scroll down 100pt before marking resumes.
// Requires genuine downward scroll before marking resumes.
trackingActive = false
cumulativeDown = 0
isScrollingDown = false
@@ -78,12 +82,18 @@ struct EntryListView: View {
// MARK: - Scroll Direction + Activation
/// Dynamic threshold: max(100pt, 20% of viewport).
/// Taller screens require more scroll before tracking activates.
private var activationThreshold: CGFloat {
max(100, viewportHeight * 0.2)
}
private func handleScrollOffset(_ newOffset: CGFloat) {
let delta = newOffset - previousOffset
previousOffset = newOffset
// Ignore tiny deltas (noise from layout)
guard abs(delta) > 1 else { return }
// Ignore micro deltas (<2pt) layout noise, rubber-banding
guard abs(delta) > 2 else { return }
if delta > 0 {
// Scrolling down
@@ -93,7 +103,7 @@ struct EntryListView: View {
trackingActive = true
}
} else {
// Scrolling up pause tracking, don't reset cumulative
// Scrolling up pause marking, don't reset cumulative
isScrollingDown = false
}
}
@@ -104,18 +114,37 @@ struct EntryListView: View {
content
.background(
GeometryReader { geo in
let frame = geo.frame(in: .named("readerScroll"))
Color.clear
.onChange(of: geo.frame(in: .named("readerScroll")).maxY) { oldMaxY, newMaxY in
// Only mark if:
// 1. Tracking is active (user scrolled down 100pt)
// 2. User is scrolling downward
.onChange(of: frame.minY) { _, _ in
// Track visibility: if >=50% of the entry is within the viewport,
// record it as "was visible". Only entries that were genuinely seen
// can later be marked as read on scroll-past.
let entryHeight = frame.height
guard entryHeight > 0 else { return }
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(entry.id)
}
}
.onChange(of: frame.maxY) { oldMaxY, newMaxY in
// Mark as read when entry scrolls above viewport.
// All 6 conditions must be true:
// 1. Tracking active (scrolled past dynamic threshold)
// 2. Scrolling downward
// 3. Entry is unread
// 4. Entry hasn't been marked by scroll already
// 5. Entry's bottom edge just crossed above viewport (old >= 0, new < 0)
// 4. Not already marked by scroll
// 5. Was >=50% visible at some point (genuinely seen)
// 6. Bottom edge just crossed above viewport
guard trackingActive,
isScrollingDown,
!entry.isRead,
!markedByScroll.contains(entry.id),
wasVisible.contains(entry.id),
oldMaxY >= 0,
newMaxY < 0 else { return }