From 1f32e5436e6b40f6b30f95ffbdbe04c6e4732d24 Mon Sep 17 00:00:00 2001 From: Yusuf Suleman Date: Fri, 3 Apr 2026 23:18:53 -0500 Subject: [PATCH] refine: scroll mark-as-read with visibility, dynamic threshold, stable deltas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Features/Reader/Views/EntryListView.swift | 57 ++++++++++++++----- 1 file changed, 43 insertions(+), 14 deletions(-) diff --git a/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift b/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift index 1eb45fb..008af4e 100644 --- a/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift +++ b/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift @@ -21,10 +21,8 @@ struct EntryListView: View { @State private var trackingActive = false @State private var cumulativeDown: CGFloat = 0 @State private var markedByScroll: Set = [] - - // 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 = [] // 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 }