1

I create UIScrollView to be integrated inside SwiftUI view. It contains UIHostingController to host SwiftUI view. When I update UIHostingController, UIScrollView does not change its constraints. I can scroll neither to top nor to bottom. When I try to call viewDidLoad() inside updateUIViewController(_:context:), it works like I expect. Here is my sample code,

struct ContentView: View {
@State private var max = 100
var body: some View {
    VStack {
        Button("Add") { self.max += 2 }
            ScrollableView {
                ForEach(0..<self.max, id: \.self) { index in
                    Text("Hello \(index)")
                        .frame(width: UIScreen.main.bounds.width, height: 100)
                        .background(Color(red: Double.random(in: 0...255) / 255, green: Double.random(in: 0...255) / 255, blue: Double.random(in: 0...255) / 255))
                }
            }
        }
    }
}
class ScrollViewController<Content: View>: UIViewController, UIScrollViewDelegate {
    var hostingController: UIHostingController<Content>! = nil

    init(rootView: Content) {
        self.hostingController = UIHostingController<Content>(rootView: rootView)
        super.init(nibName: nil, bundle: nil)
    }
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    var scrollView: UIScrollView = UIScrollView()

    override func viewDidLoad() {
        self.view = UIView()
        self.addChild(hostingController)
        view.addSubview(scrollView)
        scrollView.addSubview(hostingController.view)

        scrollView.delegate = self
        scrollView.scrollsToTop = true
        scrollView.isScrollEnabled = true

        makeConstraints()

        hostingController.didMove(toParent: self)
    }

    func makeConstraints() {
        scrollView.widthAnchor.constraint(equalTo: view.widthAnchor).isActive = true
        scrollView.heightAnchor.constraint(equalTo: view.heightAnchor).isActive = true

        hostingController.view.widthAnchor.constraint(equalTo: scrollView.widthAnchor).isActive = true
        hostingController.view.topAnchor.constraint(equalTo: scrollView.topAnchor).isActive = true
        hostingController.view.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor).isActive = true

        hostingController.view.translatesAutoresizingMaskIntoConstraints = false
        scrollView.translatesAutoresizingMaskIntoConstraints = false
    }
}
struct ScrollableView<Content: View>: UIViewControllerRepresentable {
    var content: () -> Content

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

    func makeUIViewController(context: Context) -> ScrollViewController<Content> {
        let vc = ScrollViewController(rootView: self.content())
        return vc
    }
    func updateUIViewController(_ viewController: ScrollViewController<Content>, context: Context) {
        viewController.hostingController.rootView = self.content()
        viewController.viewDidLoad()
    }
}

I don't think it is a good way to do. I want to know if there is the best way to update controller. If anyone knows the best solution, share me please. Thanks.

Rob
  • 415,655
  • 72
  • 787
  • 1,044
Dscyre Scotti
  • 1,307
  • 10
  • 17

1 Answers1

3

You are correct, we should never call our own viewDidLoad.


Let’s diagnose the issue, using the view debugger. So, for example, here it is (setting max to 8 to keep it manageable):

before

Note the height of the hosting controller’s view is 800 (because we have 8 subviews, 100 pt each). So far, so good.

Now tap the “add” button and repeat:

enter image description here

We can see that the problem isn’t the scroll view, but rather the hosting view controller’s view. Even though there are now 10 items, it still thinks the hosting view controller’s view’s height is 800.

So, we can call setNeedsUpdateConstraints and that fixes the problem:

func updateUIViewController(_ viewController: ScrollViewController<Content>, context: Context) {
    viewController.hostingController.rootView = content()
    viewController.hostingController.view.setNeedsUpdateConstraints()
}

Thus:

struct ContentView: View {
    @State private var max = 8

    var body: some View {
        GeometryReader { geometry in                  // don't reference `UIScreen.main.bounds` as that doesn’t work in split screen multitasking
            VStack {
                Button("Add") { self.max += 2 }
                ScrollableView {
                    ForEach(0..<self.max, id: \.self) { index in
                        Text("Hello \(index)")
                            .frame(width: geometry.size.width, height: 100)
                            .background(Color(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)))
                    }
                }
            }
        }
    }
}

class ScrollViewController<Content: View>: UIViewController {
    var hostingController: UIHostingController<Content>! = nil

    init(rootView: Content) {
        self.hostingController = UIHostingController<Content>(rootView: rootView)
        super.init(nibName: nil, bundle: nil)
    }

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

    var scrollView = UIScrollView()

    override func viewDidLoad() {
        super.viewDidLoad()                            // you need to call `super`
        // self.view = UIView()                        // don't set `self.view`

        addChild(hostingController)
        view.addSubview(scrollView)
        scrollView.addSubview(hostingController.view)

        // scrollView.delegate = self                  // you're not currently using this delegate protocol, so we probably shouldn't set the delegate

        // scrollView.scrollsToTop = true              // these are the default values
        // scrollView.isScrollEnabled = true

        makeConstraints()

        hostingController.didMove(toParent: self)
    }

    func makeConstraints() {
        NSLayoutConstraint.activate([
            // constraints for scroll view w/in main view

            scrollView.topAnchor.constraint(equalTo: view.topAnchor),
            scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),

            // define contentSize of scroll view relative to hosting controller's view

            hostingController.view.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
            hostingController.view.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor),
            hostingController.view.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
            hostingController.view.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor)
        ])

        hostingController.view.translatesAutoresizingMaskIntoConstraints = false
        scrollView.translatesAutoresizingMaskIntoConstraints = false
    }
}

struct ScrollableView<Content: View>: UIViewControllerRepresentable {
    var content: () -> Content

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

    func makeUIViewController(context: Context) -> ScrollViewController<Content> {
        ScrollViewController(rootView: content())
    }

    func updateUIViewController(_ viewController: ScrollViewController<Content>, context: Context) {
        viewController.hostingController.rootView = content()
        viewController.hostingController.view.setNeedsUpdateConstraints()
    }
}
Rob
  • 415,655
  • 72
  • 787
  • 1,044
  • 1
    Thanks for your detailed explanations. – Dscyre Scotti May 17 '20 at 02:38
  • @Rob Any idea, why a SwiftUI List being presented in this very ScrollableView does not show at all? As a test I'd added `List { Text("Abc") }`. – Frederik Winkelsdorf Dec 20 '20 at 15:23
  • Addendum (was too slow for an Edit): Both HostingView and ViewHost of the ScrollViewController have a size of 1x1 px as soon as a List is given as Content. – Frederik Winkelsdorf Dec 20 '20 at 15:37
  • Nevermind, fixed it by using a Closure Parameter (`var content: (CGSize) -> Content`) and returning the size of the frame of the ViewController in `updateUIViewController`. Now the content can use the given size to resize itself using `.frame`. Solves the problem with 1x1 size of a child List. – Frederik Winkelsdorf Dec 21 '20 at 11:29