diff --git a/ios/Platform/Platform.xcodeproj/project.pbxproj b/ios/Platform/Platform.xcodeproj/project.pbxproj index 98a81b1..0be74ba 100644 --- a/ios/Platform/Platform.xcodeproj/project.pbxproj +++ b/ios/Platform/Platform.xcodeproj/project.pbxproj @@ -50,6 +50,7 @@ A10045 /* ArticleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10045 /* ArticleView.swift */; }; A10046 /* ArticleWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10046 /* ArticleWebView.swift */; }; A10047 /* FeedManagementSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10047 /* FeedManagementSheet.swift */; }; + A10048 /* ScrollViewDriver.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10048 /* ScrollViewDriver.swift */; }; F20549752F805F5800AE8DF5 /* ConfettiSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = F20549742F805F5800AE8DF5 /* ConfettiSwiftUI */; }; /* End PBXBuildFile section */ @@ -97,6 +98,7 @@ B10045 /* ArticleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleView.swift; sourceTree = ""; }; B10046 /* ArticleWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleWebView.swift; sourceTree = ""; }; B10047 /* FeedManagementSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedManagementSheet.swift; sourceTree = ""; }; + B10048 /* ScrollViewDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewDriver.swift; sourceTree = ""; }; C10001 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; D10001 /* Platform.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Platform.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -336,6 +338,7 @@ B10045 /* ArticleView.swift */, B10046 /* ArticleWebView.swift */, B10047 /* FeedManagementSheet.swift */, + B10048 /* ScrollViewDriver.swift */, ); path = Views; sourceTree = ""; @@ -454,6 +457,7 @@ A10045 /* ArticleView.swift in Sources */, A10046 /* ArticleWebView.swift in Sources */, A10047 /* FeedManagementSheet.swift in Sources */, + A10048 /* ScrollViewDriver.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift b/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift index 9ddb412..a69e03e 100644 --- a/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift +++ b/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift @@ -3,6 +3,8 @@ import SwiftUI struct EntryListView: View { @Bindable var vm: ReaderViewModel var isCardView: Bool = true + @Binding var isAutoScrolling: Bool + var scrollSpeed: Double = 1.0 // Scroll-based read tracking — all driven from per-row GeometryReader @State private var lastKnownMinY: [Int: CGFloat] = [:] // entry.id → last minY @@ -35,6 +37,10 @@ struct EntryListView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ScrollView { + // Auto-scroll engine — zero-size, drives parent UIScrollView + ScrollViewDriver(isScrolling: $isAutoScrolling, speed: scrollSpeed) + .frame(width: 0, height: 0) + if isCardView { cardLayout } else { @@ -49,6 +55,8 @@ struct EntryListView: View { cumulativeDown = 0 cumulativeUp = 0 lastKnownMinY.removeAll() + // Stop auto-scroll on navigation return + isAutoScrolling = false } .refreshable { await vm.refresh() diff --git a/ios/Platform/Platform/Features/Reader/Views/ReaderTabView.swift b/ios/Platform/Platform/Features/Reader/Views/ReaderTabView.swift index 848e163..106d992 100644 --- a/ios/Platform/Platform/Features/Reader/Views/ReaderTabView.swift +++ b/ios/Platform/Platform/Features/Reader/Views/ReaderTabView.swift @@ -6,6 +6,8 @@ struct ReaderTabView: View { @State private var showFeedSheet = false @State private var showFeedManagement = false @State private var isCardView = true + @State private var isAutoScrolling = false + @State private var scrollSpeed: Double = 1.0 var body: some View { NavigationStack { @@ -83,12 +85,46 @@ struct ReaderTabView: View { .frame(height: 44) // Fixed height prevents layout shift // Entry list - EntryListView(vm: vm, isCardView: isCardView) + EntryListView(vm: vm, isCardView: isCardView, isAutoScrolling: $isAutoScrolling, scrollSpeed: scrollSpeed) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.canvas) .navigationBarHidden(true) .toolbar { + ToolbarItem(placement: .topBarTrailing) { + // Auto-scroll controls + HStack(spacing: 8) { + if isAutoScrolling { + Button { + scrollSpeed = max(0.25, scrollSpeed - 0.25) + } label: { + Image(systemName: "minus") + .font(.caption2.weight(.bold)) + .foregroundStyle(Color.accentWarm) + } + + Text(String(format: "%.2fx", scrollSpeed)) + .font(.caption.weight(.semibold).monospacedDigit()) + .foregroundStyle(Color.textPrimary) + .frame(width: 42) + + Button { + scrollSpeed = min(3.0, scrollSpeed + 0.25) + } label: { + Image(systemName: "plus") + .font(.caption2.weight(.bold)) + .foregroundStyle(Color.accentWarm) + } + } + + Button { + isAutoScrolling.toggle() + } label: { + Image(systemName: isAutoScrolling ? "stop.fill" : "play.fill") + .foregroundStyle(isAutoScrolling ? .red : Color.accentWarm) + } + } + } ToolbarItem(placement: .topBarTrailing) { Button { withAnimation(.easeInOut(duration: 0.2)) { @@ -142,6 +178,12 @@ struct ReaderTabView: View { .onAppear { ArticleRenderer.shared.reWarmIfNeeded() } + .onChange(of: selectedSubTab) { _, _ in + isAutoScrolling = false + } + .onChange(of: vm.currentFilter) { _, _ in + isAutoScrolling = false + } } private var subTabs: [String] { ["Unread", "Starred", "All"] } diff --git a/ios/Platform/Platform/Features/Reader/Views/ScrollViewDriver.swift b/ios/Platform/Platform/Features/Reader/Views/ScrollViewDriver.swift new file mode 100644 index 0000000..96eec31 --- /dev/null +++ b/ios/Platform/Platform/Features/Reader/Views/ScrollViewDriver.swift @@ -0,0 +1,171 @@ +import SwiftUI +import UIKit + +/// Drives smooth auto-scroll on the parent UIScrollView. +/// Uses CADisplayLink for 60fps continuous motion. +/// Detects user touch to stop auto-scroll immediately. +struct ScrollViewDriver: UIViewRepresentable { + @Binding var isScrolling: Bool + let speed: Double // 1.0 = 60pt/sec + + func makeUIView(context: Context) -> UIView { + let view = DriverView() + view.coordinator = context.coordinator + view.frame = .zero + view.isUserInteractionEnabled = false + return view + } + + func updateUIView(_ uiView: UIView, context: Context) { + guard let driver = uiView as? DriverView else { return } + let coordinator = context.coordinator + coordinator.speed = speed + coordinator.isScrollingBinding = $isScrolling + + if isScrolling && coordinator.displayLink == nil { + coordinator.startScrolling(in: driver) + } else if !isScrolling && coordinator.displayLink != nil { + coordinator.stopScrolling() + } + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + // MARK: - DriverView + + /// Subclass that finds the parent UIScrollView once added to the hierarchy. + class DriverView: UIView { + weak var coordinator: Coordinator? + + override func didMoveToWindow() { + super.didMoveToWindow() + if window != nil { + coordinator?.findScrollView(from: self) + } + } + } + + // MARK: - Coordinator + + class Coordinator: NSObject, UIScrollViewDelegate { + weak var scrollView: UIScrollView? + var displayLink: CADisplayLink? + var speed: Double = 1.0 + var isScrollingBinding: Binding? + private var originalDelegate: UIScrollViewDelegate? + private var delegateInstalled = false + + func findScrollView(from view: UIView) { + var current: UIView? = view.superview + while let v = current { + if let sv = v as? UIScrollView { + scrollView = sv + installDelegate(on: sv) + return + } + current = v.superview + } + } + + private func installDelegate(on scrollView: UIScrollView) { + guard !delegateInstalled else { return } + delegateInstalled = true + // Store original delegate (SwiftUI's) and intercept + originalDelegate = scrollView.delegate + scrollView.delegate = self + } + + func startScrolling(in view: UIView) { + // Re-find scroll view if needed + if scrollView == nil { + findScrollView(from: view) + } + guard scrollView != nil else { return } + + let link = CADisplayLink(target: self, selector: #selector(tick)) + link.preferredFrameRateRange = CAFrameRateRange(minimum: 30, maximum: 60) + link.add(to: .main, forMode: .common) + displayLink = link + } + + func stopScrolling() { + displayLink?.invalidate() + displayLink = nil + } + + @objc private func tick(_ link: CADisplayLink) { + guard let sv = scrollView else { + stopAndNotify() + return + } + + let maxOffset = sv.contentSize.height - sv.bounds.height + sv.contentInset.bottom + guard maxOffset > 0 else { return } + + // 60pt/sec at 1.0x speed, scaled by actual frame duration + let delta = CGFloat(speed) * 60.0 * CGFloat(link.targetTimestamp - link.timestamp) + let newY = min(sv.contentOffset.y + delta, maxOffset) + + sv.contentOffset.y = newY + + // Stop at bottom + if newY >= maxOffset - 1 { + stopAndNotify() + } + } + + private func stopAndNotify() { + stopScrolling() + DispatchQueue.main.async { [weak self] in + self?.isScrollingBinding?.wrappedValue = false + } + } + + // MARK: - UIScrollViewDelegate (intercept user touch) + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + // User touched the scroll view — stop auto-scroll + if displayLink != nil { + stopAndNotify() + } + // Forward to original delegate + originalDelegate?.scrollViewWillBeginDragging?(scrollView) + } + + // Forward all other delegate methods to original + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + originalDelegate?.scrollViewDidScroll?(scrollView) + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + originalDelegate?.scrollViewDidEndDragging?(scrollView, willDecelerate: decelerate) + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + originalDelegate?.scrollViewDidEndDecelerating?(scrollView) + } + + func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool { + originalDelegate?.scrollViewShouldScrollToTop?(scrollView) ?? true + } + + func scrollViewDidScrollToTop(_ scrollView: UIScrollView) { + originalDelegate?.scrollViewDidScrollToTop?(scrollView) + } + + func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { + originalDelegate?.scrollViewWillEndDragging?(scrollView, withVelocity: velocity, targetContentOffset: targetContentOffset) + } + + deinit { + displayLink?.invalidate() + // Restore original delegate + if let sv = scrollView, delegateInstalled { + sv.delegate = originalDelegate + } + } + } +}