fix: eliminate auto-scroll jitter by deferring mark-as-read visual updates
ROOT CAUSE (confirmed by instrumentation): Every markRead during auto-scroll mutated entries[idx].status, causing SwiftUI to re-render the row (bold→regular, dot removed). This changed card height, causing contentSize jumps of 10-128pt per entry — visible as jitter. FIX: During auto-scroll, collect entry IDs in deferredReadIDs instead of mutating entries array. When auto-scroll stops, flushDeferredReads() applies all status changes at once and sends a single batched API call. Manual scroll still marks immediately (no deferral needed since the user controls the scroll position). Removed all debug instrumentation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -94,19 +94,15 @@ final class ReaderViewModel {
|
|||||||
func loadMore() async {
|
func loadMore() async {
|
||||||
guard !isLoadingMore, hasMore else { return }
|
guard !isLoadingMore, hasMore else { return }
|
||||||
isLoadingMore = true
|
isLoadingMore = true
|
||||||
print("[SCROLL-DBG] 📥 loadMore START offset=\(offset)")
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let list = try await fetchEntries(offset: offset)
|
let list = try await fetchEntries(offset: offset)
|
||||||
let count = list.entries.count
|
|
||||||
entries.append(contentsOf: list.entries)
|
entries.append(contentsOf: list.entries)
|
||||||
total = list.total
|
total = list.total
|
||||||
offset += count
|
offset += list.entries.count
|
||||||
hasMore = offset < list.total
|
hasMore = offset < list.total
|
||||||
print("[SCROLL-DBG] 📥 loadMore END appended=\(count) total=\(entries.count) hasMore=\(hasMore)")
|
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
print("[SCROLL-DBG] 📥 loadMore FAILED: \(error)")
|
|
||||||
}
|
}
|
||||||
isLoadingMore = false
|
isLoadingMore = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ struct EntryListView: View {
|
|||||||
@State private var cumulativeDown: CGFloat = 0
|
@State private var cumulativeDown: CGFloat = 0
|
||||||
@State private var cumulativeUp: CGFloat = 0
|
@State private var cumulativeUp: CGFloat = 0
|
||||||
@State private var markedByScroll: Set<Int> = []
|
@State private var markedByScroll: Set<Int> = []
|
||||||
|
@State private var deferredReadIDs: Set<Int> = [] // IDs to mark read when auto-scroll stops
|
||||||
|
|
||||||
// Viewport height — use the first connected scene's screen.
|
// Viewport height — use the first connected scene's screen.
|
||||||
private var viewportHeight: CGFloat {
|
private var viewportHeight: CGFloat {
|
||||||
@@ -59,6 +60,11 @@ struct EntryListView: View {
|
|||||||
// Stop auto-scroll on navigation return
|
// Stop auto-scroll on navigation return
|
||||||
isAutoScrolling = false
|
isAutoScrolling = false
|
||||||
}
|
}
|
||||||
|
.onChange(of: isAutoScrolling) { _, scrolling in
|
||||||
|
if !scrolling {
|
||||||
|
flushDeferredReads()
|
||||||
|
}
|
||||||
|
}
|
||||||
.refreshable {
|
.refreshable {
|
||||||
await vm.refresh()
|
await vm.refresh()
|
||||||
}
|
}
|
||||||
@@ -112,22 +118,49 @@ struct EntryListView: View {
|
|||||||
frame.maxY < 30 else { return }
|
frame.maxY < 30 else { return }
|
||||||
|
|
||||||
markedByScroll.insert(entryId)
|
markedByScroll.insert(entryId)
|
||||||
print("[SCROLL-DBG] 📖 markRead id=\(entryId) entriesCount=\(vm.entries.count)")
|
|
||||||
|
|
||||||
if let idx = vm.entries.firstIndex(where: { $0.id == entryId }) {
|
if isAutoScrolling {
|
||||||
vm.entries[idx].status = "read"
|
// Defer visual update — contentSize changes cause jitter
|
||||||
}
|
// during auto-scroll. Collect IDs, apply when scroll stops.
|
||||||
|
deferredReadIDs.insert(entryId)
|
||||||
Task {
|
} else {
|
||||||
let api = ReaderAPI()
|
// Manual scroll — apply immediately
|
||||||
try? await api.markEntries(ids: [entryId], status: "read")
|
if let idx = vm.entries.firstIndex(where: { $0.id == entryId }) {
|
||||||
vm.counters = try? await api.getCounters()
|
vm.entries[idx].status = "read"
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
let api = ReaderAPI()
|
||||||
|
try? await api.markEntries(ids: [entryId], status: "read")
|
||||||
|
vm.counters = try? await api.getCounters()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Flush Deferred Reads
|
||||||
|
|
||||||
|
private func flushDeferredReads() {
|
||||||
|
guard !deferredReadIDs.isEmpty else { return }
|
||||||
|
let ids = Array(deferredReadIDs)
|
||||||
|
deferredReadIDs.removeAll()
|
||||||
|
|
||||||
|
// Apply all visual updates at once
|
||||||
|
for id in ids {
|
||||||
|
if let idx = vm.entries.firstIndex(where: { $0.id == id }) {
|
||||||
|
vm.entries[idx].status = "read"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single batched API call
|
||||||
|
Task {
|
||||||
|
let api = ReaderAPI()
|
||||||
|
try? await api.markEntries(ids: ids, status: "read")
|
||||||
|
vm.counters = try? await api.getCounters()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Card Layout
|
// MARK: - Card Layout
|
||||||
|
|
||||||
private var cardLayout: some View {
|
private var cardLayout: some View {
|
||||||
|
|||||||
@@ -94,43 +94,20 @@ struct ScrollViewDriver: UIViewRepresentable {
|
|||||||
displayLink = nil
|
displayLink = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private var lastTickTime: CFAbsoluteTime = 0
|
|
||||||
private var lastContentSize: CGFloat = 0
|
|
||||||
private var tickCount = 0
|
|
||||||
|
|
||||||
@objc private func tick(_ link: CADisplayLink) {
|
@objc private func tick(_ link: CADisplayLink) {
|
||||||
guard let sv = scrollView else {
|
guard let sv = scrollView else {
|
||||||
stopAndNotify()
|
stopAndNotify()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let now = CFAbsoluteTimeGetCurrent()
|
|
||||||
let maxOffset = sv.contentSize.height - sv.bounds.height + sv.contentInset.bottom
|
let maxOffset = sv.contentSize.height - sv.bounds.height + sv.contentInset.bottom
|
||||||
guard maxOffset > 0 else { return }
|
guard maxOffset > 0 else { return }
|
||||||
|
|
||||||
let frameDuration = link.targetTimestamp - link.timestamp
|
let delta = CGFloat(speed) * 60.0 * CGFloat(link.targetTimestamp - link.timestamp)
|
||||||
let expectedDelta = CGFloat(speed) * 60.0 * CGFloat(frameDuration)
|
let newY = min(sv.contentOffset.y + delta, maxOffset)
|
||||||
let beforeY = sv.contentOffset.y
|
|
||||||
let newY = min(beforeY + expectedDelta, maxOffset)
|
|
||||||
|
|
||||||
sv.contentOffset.y = newY
|
sv.contentOffset.y = newY
|
||||||
|
|
||||||
let actualDelta = sv.contentOffset.y - beforeY
|
|
||||||
|
|
||||||
// Detect jitter: contentSize changed, or actual delta differs from expected
|
|
||||||
let contentSizeChanged = sv.contentSize.height != lastContentSize
|
|
||||||
let wallDelta = lastTickTime > 0 ? (now - lastTickTime) * 1000 : 0
|
|
||||||
let isJitter = contentSizeChanged || wallDelta > 25 // >25ms between frames = dropped frame
|
|
||||||
|
|
||||||
tickCount += 1
|
|
||||||
if isJitter || tickCount % 120 == 0 {
|
|
||||||
// Log on jitter or every 2 seconds
|
|
||||||
print("[SCROLL-DBG] \(isJitter ? "⚠️ JITTER" : "✅ ok") wallΔ=\(String(format:"%.1f", wallDelta))ms frameDur=\(String(format:"%.1f", frameDuration*1000))ms expΔ=\(String(format:"%.1f", expectedDelta))pt actΔ=\(String(format:"%.1f", actualDelta))pt y=\(Int(sv.contentOffset.y)) contentH=\(Int(sv.contentSize.height)) csChanged=\(contentSizeChanged) speed=\(String(format:"%.2f", speed))")
|
|
||||||
}
|
|
||||||
|
|
||||||
lastTickTime = now
|
|
||||||
lastContentSize = sv.contentSize.height
|
|
||||||
|
|
||||||
originalDelegate?.scrollViewDidScroll?(sv)
|
originalDelegate?.scrollViewDidScroll?(sv)
|
||||||
|
|
||||||
if newY >= maxOffset - 1 {
|
if newY >= maxOffset - 1 {
|
||||||
|
|||||||
Reference in New Issue
Block a user