feat: scroll-based mark-as-read with geometry tracking + navigation protection
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:
@@ -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<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 {
|
||||
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,12 +76,74 @@ 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) {
|
||||
scrollTracked(entry,
|
||||
content: NavigationLink(value: entry) {
|
||||
EntryCardView(entry: entry)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -47,6 +151,7 @@ struct EntryListView: View {
|
||||
.contextMenu {
|
||||
entryContextMenu(entry: entry, vm: vm)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
loadMoreTrigger
|
||||
@@ -62,7 +167,8 @@ struct EntryListView: View {
|
||||
private var listLayout: some View {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(vm.entries) { entry in
|
||||
NavigationLink(value: entry) {
|
||||
scrollTracked(entry,
|
||||
content: NavigationLink(value: entry) {
|
||||
EntryRowView(entry: entry)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -70,6 +176,7 @@ struct EntryListView: View {
|
||||
.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)
|
||||
|
||||
Reference in New Issue
Block a user