KAMIKAZE
KAMIKAZE

Reputation: 510

Detect when ScrollView has finished scrolling in iOS 17 SwiftUI

Using iOS 17 as minimum target and .scrollTargetBehavior(.paging) modifier.

I need a callback similar to scrollViewDidEndScrollingAnimation. The goal is to get the callback only after the page changes and the animation finishes, not during the scrolling process or in a middle or so.

This is not a new question, but many other answer I've tried didn't worked well. Is there maybe a new solution, specially if minimum iOS target for my project is iOS17?

Upvotes: 1

Views: 360

Answers (1)

Sweeper
Sweeper

Reputation: 273540

In iOS 18 there is onScrollPhaseChange, so you are not concerned with forward-compatibility. You can use SwiftUI-Introspect to intercept the scroll view delegate calls.

You write your own UIScrollViewDelegate that wraps the built-in delegate of ScrollView (after all, SwiftUI also needs to set its own delegate for its own purposes), and forwards every delegate method.

class ScrollViewObserver: NSObject, ObservableObject, UIScrollViewDelegate {
    var wrapped: (any UIScrollViewDelegate)?
    var action: (() -> Void)?
    weak var scrollView: UIScrollView?
    
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        wrapped?.scrollViewDidScroll?(scrollView)
    }
    
    func scrollViewDidZoom(_ scrollView: UIScrollView) {
        wrapped?.scrollViewDidZoom?(scrollView)
    }
    
    func scrollViewDidScrollToTop(_ scrollView: UIScrollView) {
        wrapped?.scrollViewDidScrollToTop?(scrollView)
    }
    
    func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        wrapped?.scrollViewWillBeginDragging?(scrollView)
    }
    
    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        wrapped?.scrollViewDidEndDecelerating?(scrollView)
        action?()
    }
    
    func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
        wrapped?.scrollViewShouldScrollToTop?(scrollView) ?? true
    }
    
    func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView) {
        wrapped?.scrollViewWillBeginDecelerating?(scrollView)
    }
    
    func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
        wrapped?.scrollViewDidEndScrollingAnimation?(scrollView)
    }
    
    func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) {
        wrapped?.scrollViewWillBeginZooming?(scrollView, with: view)
    }
    
    func scrollViewDidChangeAdjustedContentInset(_ scrollView: UIScrollView) {
        wrapped?.scrollViewDidChangeAdjustedContentInset?(scrollView)
    }
    
    func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
        wrapped?.scrollViewDidEndDragging?(scrollView, willDecelerate: decelerate)
    }
    
    func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
        wrapped?.scrollViewDidEndZooming?(scrollView, with: view, atScale: scale)
    }
    
    func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
        wrapped?.scrollViewWillEndDragging?(scrollView, withVelocity: velocity, targetContentOffset: targetContentOffset)
    }
}

Then in introspect, you can replace the scroll view's delegate with an instance of ScrollViewObserver,

struct OnScrollEndModifier: ViewModifier {
    @StateObject var observer = ScrollViewObserver()
    let action: () -> Void
    
    func body(content: Content) -> some View {
        if #available(iOS 18, *) {
            content
                .onScrollPhaseChange { oldPhase, newPhase in
                    if newPhase == .idle && oldPhase != .idle {
                        action()
                    }
                }
        } else {
            content
                .introspect(.scrollView, on: .iOS(.v17)) { scrollView in
                    observer.action = action
                    // only replace the delegate when this is a new scroll view we haven't seen
                    if observer.scrollView != scrollView {
                        observer.wrapped = scrollView.delegate
                        scrollView.delegate = observer
                        observer.scrollView = scrollView
                    }
                }
        }
    }
}

extension View {
    func onScrollEnd(_ action: @escaping () -> Void) -> some View {
        modifier(OnScrollEndModifier(action: action))
    }
}

Usage:

ScrollView {
    // ...
}
.scrollTargetBehavior(.paging)
.onScrollEnd {
    print("Ended!")
}

Upvotes: 2

Related Questions