Skip to content

Hooking into UIScrollView from SwiftUI ScrollView

Posted on:February 8, 2023 at 03:22 PM

The SwiftUI ScrollView currently lacks support for setting the content offset. There also isn’t a way to determine if the user has started or finished dragging the ScrollView. I wanted to tap into the functionality of UIScrollView while still retaining the original composition of ScrollView in my SwiftUI code. By placing the ScrollView content in a UIHostingController and then converting it to a UIViewControllerRepresentable, it is possible to achieve this. The UIHostingController is defined as follows:

import SwiftUI

final class ScrollViewUIHostingController<Content>:
    UIHostingController<Content>, UIScrollViewDelegate where Content: View {

    // MARK: - Properties

    private let isScrolling: Binding<Bool>
    private var contentOffset: Binding<CGPoint>
    private var scrollView: UIScrollView?
    private var isFirstViewDidAppear = true

    // MARK: - Initializers


    init(isScrolling: Binding<Bool>, contentOffset: Binding<CGPoint>, rootView: Content) {
        self.isScrolling = isScrolling
        self.contentOffset = contentOffset

        super.init(rootView: rootView)
    }

    @objc dynamic required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    // MARK: - View Lifecycle

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .clear
    }

    override func viewDidAppear(_ animated: Bool) {
        // Should only search for the scroll view once
        guard isFirstViewDidAppear else { return }

        scrollView = findScrollView(in: view)
        scrollView?.delegate = self
        isFirstViewDidAppear = false
    }

    // MARK: - Scroll View Find

    func findScrollView(in view: UIView?) -> UIScrollView? {
        if let scrollView = view as? UIScrollView {
            return scrollView
        }

        for subview in view?.subviews ?? [] {
            if let scrollView = findScrollView(in: subview) {
                return scrollView
            }
        }

        return nil
    }

    // MARK: - UIScrollViewDelegate

    func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        isScrolling.wrappedValue = true
    }

    func scrollViewWillEndDragging(
        _ scrollView: UIScrollView,
        withVelocity velocity: CGPoint,
        targetContentOffset: UnsafeMutablePointer<CGPoint>
    ) {
        isScrolling.wrappedValue = false
    }

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        contentOffset.wrappedValue = scrollView.contentOffset
    }

}

Since ScrollView is actually a UIScrollView wrapped for SwiftUI, we can locate the UIScrollView during the viewDidAppear method and then set the different delegate methods.

The UIViewControllerRepresentable is defined as:

import SwiftUI

struct ScrollViewControllerRepresentable<Content>: UIViewControllerRepresentable where Content: View {

    // MARK: - Properties

    private let isScrolling: Binding<Bool>
    private let contentOffset: Binding<CGPoint>
    private let content: Content

    // MARK: - Initializers

    init(isScrolling: Binding<Bool>, contentOffset: Binding<CGPoint>, content: Content) {
        self.isScrolling = isScrolling
        self.contentOffset = contentOffset
        self.content = content
    }

    func makeUIViewController(context: Context) -> ScrollViewUIHostingController<Content> {
        ScrollViewUIHostingController(isScrolling: isScrolling, contentOffset: contentOffset, rootView: content)
    }
    
    func updateUIViewController(_ uiViewController: ScrollViewUIHostingController<Content>, context: Context) {
        uiViewController.rootView = content
    }

}

Now an extension can be added to View:

extension View {

    func UIScrollView(isScrolling: Binding<Bool>, contentOffset: Binding<CGPoint>) -> some View {
            ScrollViewControllerRepresentable(isScrolling: isScrolling, contentOffset: contentOffset, content: self)
    }

}

Finally at the call site:

var body: some View {
    ScrollView {
        LazyVStack {
            ForEach(items, id: \.self) { item in
                makeItem(text: item)
            }
        }
        .padding()
    }
    .UIScrollView(isScrolling: $isScrolling, contentOffset: $contentOffset)
}

An interesting, albeit hacky, approach to achieve this functionality. Of course if only the contentOffset functionality is needed the GeometryReader with PreferenceKey method can be used instead.