feat: Phase 1 auto-scroll engine for Reader feed
All checks were successful
Security Checks / dependency-audit (push) Successful in 23s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s

ENGINE (ScrollViewDriver.swift):
- UIViewRepresentable placed inside ScrollView (zero size)
- Finds parent UIScrollView via view hierarchy traversal
- CADisplayLink at 60fps drives contentOffset.y smoothly
- Speed: 1.0x = 60pt/sec, adjustable 0.25x–3.0x in 0.25 steps
- User touch detection: intercepts UIScrollViewDelegate
  scrollViewWillBeginDragging → stops auto-scroll immediately
- Stops at bottom (contentOffset >= maxOffset)
- Forwards all delegate methods to SwiftUI's original delegate

INTEGRATION (EntryListView):
- Accepts @Binding isAutoScrolling + scrollSpeed
- ScrollViewDriver placed as first child in ScrollView
- Auto-scroll stops on: user touch, navigation back (onAppear),
  filter change, sub-tab change, reaching bottom

CONTROLS (ReaderTabView — temporary, Phase 1):
- Play/Stop button in toolbar (play.fill / stop.fill)
- When playing: [-] speed [+] controls appear inline
- Speed shown as "1.00x" with monospacedDigit

MARK-AS-READ:
- Auto-scroll drives real UIScrollView contentOffset
- This moves LazyVStack rows, triggering their GeometryReader
  onChange callbacks — the existing mark-as-read system fires
  naturally with no special case or bypass needed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-04 06:51:08 -05:00
parent 6ed7f8a230
commit 85c3bb7a42
4 changed files with 226 additions and 1 deletions

View File

@@ -50,6 +50,7 @@
A10045 /* ArticleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10045 /* ArticleView.swift */; }; A10045 /* ArticleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10045 /* ArticleView.swift */; };
A10046 /* ArticleWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10046 /* ArticleWebView.swift */; }; A10046 /* ArticleWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10046 /* ArticleWebView.swift */; };
A10047 /* FeedManagementSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10047 /* FeedManagementSheet.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 */; }; F20549752F805F5800AE8DF5 /* ConfettiSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = F20549742F805F5800AE8DF5 /* ConfettiSwiftUI */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@@ -97,6 +98,7 @@
B10045 /* ArticleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleView.swift; sourceTree = "<group>"; }; B10045 /* ArticleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleView.swift; sourceTree = "<group>"; };
B10046 /* ArticleWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleWebView.swift; sourceTree = "<group>"; }; B10046 /* ArticleWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleWebView.swift; sourceTree = "<group>"; };
B10047 /* FeedManagementSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedManagementSheet.swift; sourceTree = "<group>"; }; B10047 /* FeedManagementSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedManagementSheet.swift; sourceTree = "<group>"; };
B10048 /* ScrollViewDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewDriver.swift; sourceTree = "<group>"; };
C10001 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; C10001 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
D10001 /* Platform.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Platform.app; sourceTree = BUILT_PRODUCTS_DIR; }; D10001 /* Platform.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Platform.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@@ -336,6 +338,7 @@
B10045 /* ArticleView.swift */, B10045 /* ArticleView.swift */,
B10046 /* ArticleWebView.swift */, B10046 /* ArticleWebView.swift */,
B10047 /* FeedManagementSheet.swift */, B10047 /* FeedManagementSheet.swift */,
B10048 /* ScrollViewDriver.swift */,
); );
path = Views; path = Views;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -454,6 +457,7 @@
A10045 /* ArticleView.swift in Sources */, A10045 /* ArticleView.swift in Sources */,
A10046 /* ArticleWebView.swift in Sources */, A10046 /* ArticleWebView.swift in Sources */,
A10047 /* FeedManagementSheet.swift in Sources */, A10047 /* FeedManagementSheet.swift in Sources */,
A10048 /* ScrollViewDriver.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };

View File

@@ -3,6 +3,8 @@ import SwiftUI
struct EntryListView: View { struct EntryListView: View {
@Bindable var vm: ReaderViewModel @Bindable var vm: ReaderViewModel
var isCardView: Bool = true var isCardView: Bool = true
@Binding var isAutoScrolling: Bool
var scrollSpeed: Double = 1.0
// Scroll-based read tracking all driven from per-row GeometryReader // Scroll-based read tracking all driven from per-row GeometryReader
@State private var lastKnownMinY: [Int: CGFloat] = [:] // entry.id last minY @State private var lastKnownMinY: [Int: CGFloat] = [:] // entry.id last minY
@@ -35,6 +37,10 @@ struct EntryListView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} else { } else {
ScrollView { ScrollView {
// Auto-scroll engine zero-size, drives parent UIScrollView
ScrollViewDriver(isScrolling: $isAutoScrolling, speed: scrollSpeed)
.frame(width: 0, height: 0)
if isCardView { if isCardView {
cardLayout cardLayout
} else { } else {
@@ -49,6 +55,8 @@ struct EntryListView: View {
cumulativeDown = 0 cumulativeDown = 0
cumulativeUp = 0 cumulativeUp = 0
lastKnownMinY.removeAll() lastKnownMinY.removeAll()
// Stop auto-scroll on navigation return
isAutoScrolling = false
} }
.refreshable { .refreshable {
await vm.refresh() await vm.refresh()

View File

@@ -6,6 +6,8 @@ struct ReaderTabView: View {
@State private var showFeedSheet = false @State private var showFeedSheet = false
@State private var showFeedManagement = false @State private var showFeedManagement = false
@State private var isCardView = true @State private var isCardView = true
@State private var isAutoScrolling = false
@State private var scrollSpeed: Double = 1.0
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -83,12 +85,46 @@ struct ReaderTabView: View {
.frame(height: 44) // Fixed height prevents layout shift .frame(height: 44) // Fixed height prevents layout shift
// Entry list // Entry list
EntryListView(vm: vm, isCardView: isCardView) EntryListView(vm: vm, isCardView: isCardView, isAutoScrolling: $isAutoScrolling, scrollSpeed: scrollSpeed)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.canvas) .background(Color.canvas)
.navigationBarHidden(true) .navigationBarHidden(true)
.toolbar { .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) { ToolbarItem(placement: .topBarTrailing) {
Button { Button {
withAnimation(.easeInOut(duration: 0.2)) { withAnimation(.easeInOut(duration: 0.2)) {
@@ -142,6 +178,12 @@ struct ReaderTabView: View {
.onAppear { .onAppear {
ArticleRenderer.shared.reWarmIfNeeded() ArticleRenderer.shared.reWarmIfNeeded()
} }
.onChange(of: selectedSubTab) { _, _ in
isAutoScrolling = false
}
.onChange(of: vm.currentFilter) { _, _ in
isAutoScrolling = false
}
} }
private var subTabs: [String] { ["Unread", "Starred", "All"] } private var subTabs: [String] { ["Unread", "Starred", "All"] }

View File

@@ -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<Bool>?
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<CGPoint>) {
originalDelegate?.scrollViewWillEndDragging?(scrollView, withVelocity: velocity, targetContentOffset: targetContentOffset)
}
deinit {
displayLink?.invalidate()
// Restore original delegate
if let sv = scrollView, delegateInstalled {
sv.delegate = originalDelegate
}
}
}
}