sheldor
sheldor

Reputation: 159

How to scroll to position UIScrollView in Wrapper for SwiftUI?

i have a ScrollView from UIKit and use it for SwiftUI: Is there any way to make a paged ScrollView in SwiftUI?

Question: How can I scroll in the UIScrollView to a position with a button click on a button in a SwiftUI View OR what is also good for my needs to scroll to a position when first displaying the ScrollView

I tried contentOffset but this didnt work. Perhaps I've done something wrong.

ScrollViewWrapper:

class UIScrollViewViewController: UIViewController {
    lazy var scrollView: UIScrollView = {
        let v = UIScrollView()
        v.isPagingEnabled = false
        v.alwaysBounceVertical = true
        return v
    }()

    var hostingController: UIHostingController<AnyView> = UIHostingController(rootView: AnyView(EmptyView()))

    override func viewDidLoad() {
        super.viewDidLoad()
        self.view.addSubview(self.scrollView)
        self.pinEdges(of: self.scrollView, to: self.view)

        self.hostingController.willMove(toParent: self)
        self.scrollView.addSubview(self.hostingController.view)
        self.pinEdges(of: self.hostingController.view, to: self.scrollView)
        self.hostingController.didMove(toParent: self)

    }

    func pinEdges(of viewA: UIView, to viewB: UIView) {
        viewA.translatesAutoresizingMaskIntoConstraints = false
        viewB.addConstraints([
        viewA.leadingAnchor.constraint(equalTo: viewB.leadingAnchor),
        viewA.trailingAnchor.constraint(equalTo: viewB.trailingAnchor),
        viewA.topAnchor.constraint(equalTo: viewB.topAnchor),
        viewA.bottomAnchor.constraint(equalTo: viewB.bottomAnchor),
        ])
    }

}

struct UIScrollViewWrapper<Content: View>: UIViewControllerRepresentable {

    var content: () -> Content

    init(@ViewBuilder content: @escaping () -> Content) {
        self.content = content
    }

    func makeUIViewController(context: Context) -> UIScrollViewViewController {
        let vc = UIScrollViewViewController()
        vc.hostingController.rootView = AnyView(self.content())
        return vc
    }

    func updateUIViewController(_ viewController: UIScrollViewViewController, context: Context) {
        viewController.hostingController.rootView = AnyView(self.content())
    }
}

SwiftUI usage:

struct ContentView: View{
    @ObservedObject var search = SearchBar()
    var body: some View{
       NavigationView{
        GeometryReader{geo in
            UIScrollViewWrapper{      //<-----------------
                VStack{
                    ForEach(0..<10){i in
                        Text("lskdfj")
                    }
                }
                .frame(width: geo.size.width)
            }
            .navigationBarTitle("Test")
        }
       }
    }
}

Upvotes: 2

Views: 2808

Answers (2)

Adrien
Adrien

Reputation: 1927

We will first declare the offset property in the UIViewControllerRepresentable, with the propertyWrapper @Binding, because its value can be changed by the scrollview or by the parent view (the ContentView).

struct UIScrollViewWrapper<Content: View>: UIViewControllerRepresentable {
    var content: () -> Content
    @Binding var offset: CGPoint
    init(offset: Binding<CGPoint>, @ViewBuilder content: @escaping () -> Content) {
        self.content = content
        _offset = offset
    }
// ....//
}

If the offset changes cause of the parent view, we must apply these changes to the scrollView in the updateUIViewController function (which is called when the state of the view changes) :

func updateUIViewController(_ viewController: UIScrollViewViewController, context: Context) {
    viewController.hostingController.rootView = AnyView(content())
    viewController.scrollView.contentOffset = offset
}

When the offset changes because the user scrolls, we must reflect this change on our Binding. To do this we must declare a Coordinator, which will be a UIScrollViewDelegate, and modify the offset in its scrollViewDidScroll function :

class Controller: NSObject, UIScrollViewDelegate {
    var parent: UIScrollViewWrapper<Content>
    init(parent: UIScrollViewWrapper<Content>) {
        self.parent = parent
    }

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

and, in struct UIScrollViewWrapper<Content: View>: UIViewControllerRepresentable

func makeCoordinator() -> Controller {
    return Controller(parent: self)
}

Finally, for the initial offset (this is important otherwise your starting offset will always be 0), this happens in the makeUIViewController: you have to add these lines:

vc.view.layoutIfNeeded ()
vc.scrollView.contentOffset = offset

The final project :

import SwiftUI

struct ContentView: View {
    @State private var offset: CGPoint = CGPoint(x: 0, y: 200)
    let texts: [String] = (1...100).map {_ in String.random(length: Int.random(in: 6...20))}
    var body: some View {
        ZStack(alignment: .top) {
            GeometryReader { geo in
                UIScrollViewWrapper(offset: $offset) { //
                    VStack {
                        Text("Start")
                            .foregroundColor(.red)
                        ForEach(texts, id: \.self) { text in
                            Text(text)
                        }
                    }
                        .padding(.top, 40)
                    
                    .frame(width: geo.size.width)
                }
                .navigationBarTitle("Test")
                
            }
            HStack {
                Text(offset.debugDescription)
                Button("add") {
                    offset.y += 100
                }
            }
            .padding(.bottom, 10)
            .frame(maxWidth: .infinity)
            .background(Color.white)
        }
    }
}

class UIScrollViewViewController: UIViewController {
    lazy var scrollView: UIScrollView = {
        let v = UIScrollView()
        v.isPagingEnabled = false
        v.alwaysBounceVertical = true
        return v
    }()

    var hostingController: UIHostingController<AnyView> = UIHostingController(rootView: AnyView(EmptyView()))

    override func viewDidLoad() {
        super.viewDidLoad()
        view.addSubview(scrollView)
        pinEdges(of: scrollView, to: view)

        hostingController.willMove(toParent: self)
        scrollView.addSubview(hostingController.view)
        pinEdges(of: hostingController.view, to: scrollView)
        hostingController.didMove(toParent: self)
    }

    func pinEdges(of viewA: UIView, to viewB: UIView) {
        viewA.translatesAutoresizingMaskIntoConstraints = false
        viewB.addConstraints([
            viewA.leadingAnchor.constraint(equalTo: viewB.leadingAnchor),
            viewA.trailingAnchor.constraint(equalTo: viewB.trailingAnchor),
            viewA.topAnchor.constraint(equalTo: viewB.topAnchor),
            viewA.bottomAnchor.constraint(equalTo: viewB.bottomAnchor),
        ])
    }
}

struct UIScrollViewWrapper<Content: View>: UIViewControllerRepresentable {
    var content: () -> Content
    @Binding var offset: CGPoint
    init(offset: Binding<CGPoint>, @ViewBuilder content: @escaping () -> Content) {
        self.content = content
        _offset = offset
    }

    func makeCoordinator() -> Controller {
        return Controller(parent: self)
    }

    func makeUIViewController(context: Context) -> UIScrollViewViewController {
        let vc = UIScrollViewViewController()
        vc.scrollView.contentInsetAdjustmentBehavior = .never
        vc.hostingController.rootView = AnyView(content())
        vc.view.layoutIfNeeded()
        vc.scrollView.contentOffset = offset
        vc.scrollView.delegate = context.coordinator
        return vc
    }

    func updateUIViewController(_ viewController: UIScrollViewViewController, context: Context) {
        viewController.hostingController.rootView = AnyView(content())
        viewController.scrollView.contentOffset = offset
    }

    class Controller: NSObject, UIScrollViewDelegate {
        var parent: UIScrollViewWrapper<Content>
        init(parent: UIScrollViewWrapper<Content>) {
            self.parent = parent
        }

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

enter image description here

Upvotes: 3

kschins
kschins

Reputation: 1224

You will need to pass a @Binding var offset: CGPoint into the UIScrollViewWrapper then when the button is clicked in your SwiftUI view, you can update the binding value which can then be used in the update method for UIViewControllerRepresentable. Another idea is to use UIViewRepresentable instead and use that with UIScrollView. Here is a helpful article doing that and setting its offset: https://www.fivestars.blog/articles/scrollview-offset/.

Upvotes: 1

Related Questions