refine: scroll mark-as-read with visibility, dynamic threshold, stable deltas
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:
@@ -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 }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user