diff --git a/ios/Platform/Platform/Features/Reader/ViewModels/ReaderViewModel.swift b/ios/Platform/Platform/Features/Reader/ViewModels/ReaderViewModel.swift index f25bca8..d496655 100644 --- a/ios/Platform/Platform/Features/Reader/ViewModels/ReaderViewModel.swift +++ b/ios/Platform/Platform/Features/Reader/ViewModels/ReaderViewModel.swift @@ -94,19 +94,15 @@ final class ReaderViewModel { func loadMore() async { guard !isLoadingMore, hasMore else { return } isLoadingMore = true - print("[SCROLL-DBG] 📥 loadMore START offset=\(offset)") do { let list = try await fetchEntries(offset: offset) - let count = list.entries.count entries.append(contentsOf: list.entries) total = list.total - offset += count + offset += list.entries.count hasMore = offset < list.total - print("[SCROLL-DBG] 📥 loadMore END appended=\(count) total=\(entries.count) hasMore=\(hasMore)") } catch { self.error = error.localizedDescription - print("[SCROLL-DBG] 📥 loadMore FAILED: \(error)") } isLoadingMore = false } diff --git a/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift b/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift index 6763ab7..bf1d378 100644 --- a/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift +++ b/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift @@ -13,6 +13,7 @@ struct EntryListView: View { @State private var cumulativeDown: CGFloat = 0 @State private var cumulativeUp: CGFloat = 0 @State private var markedByScroll: Set = [] + @State private var deferredReadIDs: Set = [] // IDs to mark read when auto-scroll stops // Viewport height — use the first connected scene's screen. private var viewportHeight: CGFloat { @@ -59,6 +60,11 @@ struct EntryListView: View { // Stop auto-scroll on navigation return isAutoScrolling = false } + .onChange(of: isAutoScrolling) { _, scrolling in + if !scrolling { + flushDeferredReads() + } + } .refreshable { await vm.refresh() } @@ -112,22 +118,49 @@ struct EntryListView: View { frame.maxY < 30 else { return } markedByScroll.insert(entryId) - print("[SCROLL-DBG] 📖 markRead id=\(entryId) entriesCount=\(vm.entries.count)") - if let idx = vm.entries.firstIndex(where: { $0.id == entryId }) { - vm.entries[idx].status = "read" - } - - Task { - let api = ReaderAPI() - try? await api.markEntries(ids: [entryId], status: "read") - vm.counters = try? await api.getCounters() + if isAutoScrolling { + // Defer visual update — contentSize changes cause jitter + // during auto-scroll. Collect IDs, apply when scroll stops. + deferredReadIDs.insert(entryId) + } else { + // Manual scroll — apply immediately + if let idx = vm.entries.firstIndex(where: { $0.id == entryId }) { + 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 private var cardLayout: some View { diff --git a/ios/Platform/Platform/Features/Reader/Views/ScrollViewDriver.swift b/ios/Platform/Platform/Features/Reader/Views/ScrollViewDriver.swift index b598b9d..6ea1102 100644 --- a/ios/Platform/Platform/Features/Reader/Views/ScrollViewDriver.swift +++ b/ios/Platform/Platform/Features/Reader/Views/ScrollViewDriver.swift @@ -94,43 +94,20 @@ struct ScrollViewDriver: UIViewRepresentable { displayLink = nil } - private var lastTickTime: CFAbsoluteTime = 0 - private var lastContentSize: CGFloat = 0 - private var tickCount = 0 - @objc private func tick(_ link: CADisplayLink) { guard let sv = scrollView else { stopAndNotify() return } - let now = CFAbsoluteTimeGetCurrent() let maxOffset = sv.contentSize.height - sv.bounds.height + sv.contentInset.bottom guard maxOffset > 0 else { return } - let frameDuration = link.targetTimestamp - link.timestamp - let expectedDelta = CGFloat(speed) * 60.0 * CGFloat(frameDuration) - let beforeY = sv.contentOffset.y - let newY = min(beforeY + expectedDelta, maxOffset) + let delta = CGFloat(speed) * 60.0 * CGFloat(link.targetTimestamp - link.timestamp) + let newY = min(sv.contentOffset.y + delta, maxOffset) 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) if newY >= maxOffset - 1 {