feat: Phase 1 auto-scroll engine for Reader feed
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:
@@ -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 = "<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>"; };
|
||||
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>"; };
|
||||
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 = "<group>";
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user