From 532a071715ade71ab6a5eab9efbef3803ebdffa9 Mon Sep 17 00:00:00 2001 From: Yusuf Suleman Date: Fri, 3 Apr 2026 23:04:32 -0500 Subject: [PATCH] feat: scroll-based mark-as-read with geometry tracking + navigation protection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uses GeometryReader + coordinate space to track entry positions — NOT onAppear/onDisappear. How it works: 1. ScrollView has a named coordinate space ("readerScroll") 2. Invisible anchor at top measures scroll offset via PreferenceKey 3. Each entry row has a background GeometryReader that tracks its frame in the scroll coordinate space 4. onChange(of: maxY) detects when an entry's bottom edge crosses above the viewport top (oldMaxY >= 0 → newMaxY < 0) 5. Entry is marked read only when ALL conditions are true: - trackingActive (user scrolled down >100pt) - isScrollingDown (current direction is down) - entry is unread - entry hasn't been marked by scroll already - entry's bottom edge just crossed above viewport Navigation protection: - onAppear resets trackingActive = false and cumulativeDown = 0 - When returning from an article, tracking is suspended - User must scroll down 100pt before tracking reactivates - This prevents all visible entries from being marked read on navigation back (they were already below viewport, not crossing) - Scrolling up never marks anything (isScrollingDown = false) State updates are local-first (immediate) with background API sync. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Features/Reader/Views/EntryListView.swift | 140 +++++++++++++++--- 1 file changed, 123 insertions(+), 17 deletions(-) diff --git a/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift b/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift index c60a532..1eb45fb 100644 --- a/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift +++ b/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift @@ -1,9 +1,31 @@ import SwiftUI +// MARK: - Scroll Offset Preference Key + +private struct ScrollOffsetKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} + +// MARK: - Entry List View + struct EntryListView: View { @Bindable var vm: ReaderViewModel var isCardView: Bool = true + // Scroll-based read tracking + @State private var previousOffset: CGFloat = 0 + @State private var isScrollingDown = false + @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 + var body: some View { if vm.isLoading && vm.entries.isEmpty { LoadingView() @@ -19,12 +41,32 @@ struct EntryListView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ScrollView { + // Invisible anchor to measure scroll offset + GeometryReader { geo in + Color.clear.preference( + key: ScrollOffsetKey.self, + value: geo.frame(in: .named("readerScroll")).minY + ) + } + .frame(height: 0) + if isCardView { cardLayout } else { listLayout } } + .coordinateSpace(name: "readerScroll") + .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. + trackingActive = false + cumulativeDown = 0 + isScrollingDown = false + } .refreshable { await vm.refresh() } @@ -34,19 +76,82 @@ struct EntryListView: View { } } + // MARK: - Scroll Direction + Activation + + private func handleScrollOffset(_ newOffset: CGFloat) { + let delta = newOffset - previousOffset + previousOffset = newOffset + + // Ignore tiny deltas (noise from layout) + guard abs(delta) > 1 else { return } + + if delta > 0 { + // Scrolling down + isScrollingDown = true + cumulativeDown += delta + if cumulativeDown > activationThreshold { + trackingActive = true + } + } else { + // Scrolling up — pause tracking, don't reset cumulative + isScrollingDown = false + } + } + + // MARK: - Entry Row with Scroll-Read Tracking + + private func scrollTracked(_ entry: ReaderEntry, content: some View) -> some View { + content + .background( + GeometryReader { geo in + 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 + // 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) + guard trackingActive, + isScrollingDown, + !entry.isRead, + !markedByScroll.contains(entry.id), + oldMaxY >= 0, + newMaxY < 0 else { return } + + markedByScroll.insert(entry.id) + + // Local-first: update immediately + if let idx = vm.entries.firstIndex(where: { $0.id == entry.id }) { + vm.entries[idx].status = "read" + } + + // API sync in background + Task { + let api = ReaderAPI() + try? await api.markEntries(ids: [entry.id], status: "read") + vm.counters = try? await api.getCounters() + } + } + } + ) + } + // MARK: - Card Layout private var cardLayout: some View { LazyVStack(spacing: 12) { ForEach(vm.entries) { entry in - NavigationLink(value: entry) { - EntryCardView(entry: entry) - } - .buttonStyle(.plain) - .contentShape(Rectangle()) - .contextMenu { - entryContextMenu(entry: entry, vm: vm) - } + scrollTracked(entry, + content: NavigationLink(value: entry) { + EntryCardView(entry: entry) + } + .buttonStyle(.plain) + .contentShape(Rectangle()) + .contextMenu { + entryContextMenu(entry: entry, vm: vm) + } + ) } loadMoreTrigger @@ -62,14 +167,16 @@ struct EntryListView: View { private var listLayout: some View { LazyVStack(spacing: 0) { ForEach(vm.entries) { entry in - NavigationLink(value: entry) { - EntryRowView(entry: entry) - } - .buttonStyle(.plain) - .contentShape(Rectangle()) - .contextMenu { - entryContextMenu(entry: entry, vm: vm) - } + scrollTracked(entry, + content: NavigationLink(value: entry) { + EntryRowView(entry: entry) + } + .buttonStyle(.plain) + .contentShape(Rectangle()) + .contextMenu { + entryContextMenu(entry: entry, vm: vm) + } + ) Divider() .padding(.leading, 36) @@ -114,7 +221,6 @@ struct EntryCardView: View { .frame(height: 180) .clipped() default: - // Reserve space during load to prevent layout jump Rectangle() .fill(Color.surfaceCard) .frame(height: 180)