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
|
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,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
|
// 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,
|
||||||
|
content: NavigationLink(value: entry) {
|
||||||
EntryCardView(entry: entry)
|
EntryCardView(entry: entry)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -47,6 +151,7 @@ struct EntryListView: View {
|
|||||||
.contextMenu {
|
.contextMenu {
|
||||||
entryContextMenu(entry: entry, vm: vm)
|
entryContextMenu(entry: entry, vm: vm)
|
||||||
}
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
loadMoreTrigger
|
loadMoreTrigger
|
||||||
@@ -62,7 +167,8 @@ 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,
|
||||||
|
content: NavigationLink(value: entry) {
|
||||||
EntryRowView(entry: entry)
|
EntryRowView(entry: entry)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -70,6 +176,7 @@ struct EntryListView: View {
|
|||||||
.contextMenu {
|
.contextMenu {
|
||||||
entryContextMenu(entry: entry, vm: vm)
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user