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 }