feat: scroll-based mark-as-read with geometry tracking + navigation protection
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s

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) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-03 23:04:32 -05:00
parent 343abb0a80
commit 532a071715

View File

@@ -1,9 +1,31 @@
import SwiftUI 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 { struct EntryListView: View {
@Bindable var vm: ReaderViewModel @Bindable var vm: ReaderViewModel
var isCardView: Bool = true 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<Int> = []
// 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 { var body: some View {
if vm.isLoading && vm.entries.isEmpty { if vm.isLoading && vm.entries.isEmpty {
LoadingView() LoadingView()
@@ -19,12 +41,32 @@ struct EntryListView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} else { } else {
ScrollView { 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 { if isCardView {
cardLayout cardLayout
} else { } else {
listLayout 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 { .refreshable {
await vm.refresh() 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 // MARK: - Card Layout
private var cardLayout: some View { private var cardLayout: some View {
LazyVStack(spacing: 12) { LazyVStack(spacing: 12) {
ForEach(vm.entries) { entry in ForEach(vm.entries) { entry in
NavigationLink(value: entry) { scrollTracked(entry,
EntryCardView(entry: entry) content: NavigationLink(value: entry) {
} EntryCardView(entry: entry)
.buttonStyle(.plain) }
.contentShape(Rectangle()) .buttonStyle(.plain)
.contextMenu { .contentShape(Rectangle())
entryContextMenu(entry: entry, vm: vm) .contextMenu {
} entryContextMenu(entry: entry, vm: vm)
}
)
} }
loadMoreTrigger loadMoreTrigger
@@ -62,14 +167,16 @@ struct EntryListView: View {
private var listLayout: some View { private var listLayout: some View {
LazyVStack(spacing: 0) { LazyVStack(spacing: 0) {
ForEach(vm.entries) { entry in ForEach(vm.entries) { entry in
NavigationLink(value: entry) { scrollTracked(entry,
EntryRowView(entry: entry) content: NavigationLink(value: entry) {
} EntryRowView(entry: entry)
.buttonStyle(.plain) }
.contentShape(Rectangle()) .buttonStyle(.plain)
.contextMenu { .contentShape(Rectangle())
entryContextMenu(entry: entry, vm: vm) .contextMenu {
} entryContextMenu(entry: entry, vm: vm)
}
)
Divider() Divider()
.padding(.leading, 36) .padding(.leading, 36)
@@ -114,7 +221,6 @@ struct EntryCardView: View {
.frame(height: 180) .frame(height: 180)
.clipped() .clipped()
default: default:
// Reserve space during load to prevent layout jump
Rectangle() Rectangle()
.fill(Color.surfaceCard) .fill(Color.surfaceCard)
.frame(height: 180) .frame(height: 180)