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 */; };
|
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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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