Adapting UIHostingController to changes in SwiftUI View size

Adapting UIHostingController to changes in SwiftUI View size

Learn how to use UIHostingController’s sizing options

Hey iOS folks! If you're using both UIKit and SwiftUI in your iOS project, you'll need to use UIHostingController as bridge between the two frameworks. UIHostingController - is a UIKit view controller that manages a SwiftUI view hierarchy. It's intended for use when integrating SwiftUI views into a UIKit view hierarchy.

While the concept may seem straightforward for experienced UIKit developers, it's easy to fall into a trap and end up with a solution like this:

import SnapKit
import SwiftUI

extension UIView {
    func addSwiftUIView<T: View>(view: T) {
        let hostingController = UIHostingController(rootView: view)
        addSubview(hostingController.view)
        hostingController.view.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }
    }
}

It can even appear to work fine until it doesn't. The issue here is that we don't retain UIHostingController after the hosting setup, and it is deallocated instantly. However, UIHostingController is intended to play an important role in UIKit/SwiftUI communication.

For example, the hosting controller can track changes to the size of its SwiftUI content using sizingOptions:

@MainActor
var sizingOptions: UIHostingControllerSizingOptions { get set }

From WWDC2022, we know that since iOS 16, you can use the .preferredContentSize and .intrinsicContentSize options on UIHostingController to automatically update the ViewController’s preferredContentSize and the view’s intrinsicContentSize. This can be enabled using the sizingOptions property on the UIHostingController. The default sizing options value is empty.

NOTE 1: The intrinsicContentSize of a UIView refers to the ideal size that a view would like to occupy based on its content. This property allows a custom view to tell its content size to the layout system without explicitly setting it. It simplifies working with Auto Layout by reducing the need for explicit constraints, making layouts more flexible and dynamic.

NOTE 2: The ViewController's preferredContentSize refers to the desired size for the content of the view controller. It allows the view controller to tell its preferred dimensions to the layout system when the view controller should be presented, especially in scenarios like popovers where specifying an ideal size is crucial for proper display. By setting the preferred content size, developers can control the presentation of view controllers and ensure that they are displayed optimally based on their content.

Let’s return to our example now. Once UIHostingController is deallocated, it can't perform its job on sizing updates if the SwiftUI view's content is changed.

So, as a first step, we can try to at least retain UIHostingController:

func addSwiftUIView<T: View>(view: T) -> UIHostingController<T> {
    let hostingController = UIHostingController(rootView: view)
    addSubview(hostingController.view)
    hostingController.view.snp.makeConstraints {
        $0.edges.equalToSuperview()
    }
    return hostingController
}

let hostingController = addSwiftUIView(SwiftUIView())
// retain UIHostingController somewhere
self.hostingController = hostingController

If we activate sizing options with .intrinsicContentSize, it will even start to work, and the hosting view will update its intrinsicContentSize when the SwiftUI view's size is changed. However, this is still not how UIHostingController is intended to be used.

NOTE: Use the hosting controller like you would any other view controller, by presenting it or embedding it as a child view controller in your interface.

So, we need to set it up like any other UIViewController by adding it as a child view controller:

import SnapKit
import SwiftUI

extension UIViewController {
        @discardableResult
    func hostSwiftUIView<T: View>(
        view: T,
        insideView hostView: UIView? = nil
    ) -> UIHostingController<T> {
        let hostingController = UIHostingController(rootView: view)
        addChild(hostingController)
        hostingController.didMove(toParent: self)
        if let hostView {
            hostView.addSubview(hostingController.view)
        } else {
            self.view.addSubview(hostingController.view)
        }
        hostingController.view.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }
        return hostingController
    }
}

NOTE: We passed hostView for cases when we want to add the hosting view as a subview to a view in the hierarchy that is not the view controller’s view.

That’s it! And don’t forget to setup sizing options of the hosting controller:

let hosting = hostSwiftUIView(view: SwiftUIView())
if #available(iOS 16.0, *) {
    hostingController.sizingOptions = [.intrinsicContentSize]
}

NOTE: We set up the sizing options outside of the hostSwiftUIView method because sizing options come with a performance cost, as the hosting controller asks for the ideal size of the content on every view update. You can move sizing options setup inside of the hostSwiftUIView if you are ready for that cost.

The happy path is complete. Let’s discuss a few edge cases next.

Before iOS 16

Sizing options are cool, but what if I need to support iOS 15 or even earlier versions?

In this case, you can still update the hosting controller’s view size explicitly by calling setNeedsUpdateConstraints() (or invalidateIntrinsicContentSize() that will eventually call setNeedsUpdateConstraints()).

func hostSwiftUIViewIfYouStillSupportOS15() {
    let swiftUIView = SwiftUIView { [weak self] in
        self?.hostingController?.view.setNeedsUpdateConstraints()
    }
    hostingController = hostSwiftUIView(
        view: swiftUIView, 
        insideView: view2
    )
}

We need to call setNeedsUpdateConstraints at the right time when view state is changed and intrinsic content size should be recalculated:

struct SwiftUIView: View {

    @State var state: ...

    var onUpdateContent: (() -> Void)?

    var body: some View {
        // some View...
        .onChange(of: state) { _ in
            onUpdateContent?()
        }
        .onAppear {
            onUpdateContent?()
        }
    }
}

In this example I had to do it also on view appear to make it work correctly. For some reason initial intrinsic size is calculated incorrectly so need to update it when view content appears.

NOTE: This solution will work for iOS 16/17 as well, but using sizingOptions is obviously much simpler and convenient option.

If you have only UIView

If you have some UIKit legacy code and you need to add SwiftUI view inside UIView and have no reference to UIViewController, you have some options as well.

extension UIView {
    func hostSwiftUIView<T: View>(view: T) -> UIHostingController<T>? {
        guard let vc = findViewController() else { return nil }
        return vc.hostSwiftUIView(
            view: view,
            insideView: self
        )
    }

    func findViewController() -> UIViewController? {
        if let nextResponder = self.next as? UIViewController {
            return nextResponder
        } else if let nextResponder = self.next as? UIView {
            return nextResponder.findViewController()
        } else {
            return nil
        }
    }
}

You can use the responder chain to get the next UIViewController in the hierarchy, but once finding the view controller is not guaranteed, that solution is not ideal. If you can avoid using it, prefer to handle this using the UIViewController.

Popovers and preferredContentSize

Now we’ve managed to get .intrinsicContentSize sizing option to work properly, but let’s see how we can use .preferredContentSize option now. If you try to present UIHostingController as a popover you will face some issues.

extension UIViewController: UIPopoverPresentationControllerDelegate {
    @discardableResult
    func presentPopover<T: View>(
        withSwiftUIView view: T,
        sourceView: UIView,
        permittedArrowDirections: UIPopoverArrowDirection = .any
    ) -> UIHostingController<T> {
        let hostingController = UIHostingController(rootView: view)
        if #available(iOS 16.0, *) {
            hostingController.sizingOptions = [.preferredContentSize]
        }
        hostingController.modalPresentationStyle = .popover
        if let popoverPC = hostingController.popoverPresentationController {
            popoverPC.permittedArrowDirections = permittedArrowDirections
            popoverPC.sourceView = sourceView
            popoverPC.delegate = self
        }
        present(hostingController, animated: true)
        return hostingController
    }
}

Let’s try a simple example again:

The first thing you can notice here is that the hosting controller doesn’t care about the minimal popover size. As you can see, it goes with the height that the hosted SwiftUI view returns to it.

You can help hosting controller by providing minimal height using frame modifier.

SwiftUIView().frame(minHeight: 40)

It looks better now:

But you can see another two issues here:

  • Animation doesn’t work properly with the popover mask. I'm not sure if we can do anything about it. Let me know if you have any ideas on how to fix it.

  • Content size updates don't work properly either. If the hosting controller returns a preferredContentSize that is bigger than the popover’s maximum size (aka screen size minus paddings), the popover will truncate the view’s size using this allowed maximum. In our case, it uses the returned preferred content height and truncates the preferred content width. To address this issue, we can provide some anchor width as the ideal width in the frame modifier. This ideal width will be used to calculate the preferredContentSize that will satisfy our expectations.

SwiftUIView().frame(idealWidth: 250, minHeight: 40)

That’s it. Now popover updates the size when content size is changed as expected.

You can find example project here.

Thanks for reading and see you in the next posts!

Did you find this article valuable?

Support Vitaly Batrakov by becoming a sponsor. Any amount is appreciated!