# Adapting UIHostingController to changes in SwiftUI View size

Hey iOS folks! If you're using both UIKit and SwiftUI in your iOS project, you'll need to use [`UIHostingController`](https://developer.apple.com/documentation/swiftui/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:

```swift
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`](https://developer.apple.com/documentation/swiftui/uihostingcontroller/sizingoptions):

```swift
@MainActor
var sizingOptions: UIHostingControllerSizingOptions { get set }
```

From [WWDC2022](https://developer.apple.com/videos/play/wwdc2022/10072/), 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`:

```swift
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](https://developer.apple.com/documentation/swiftui/uihostingcontroller).

> **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:

```swift
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:

```swift
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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1711815535081/88559a93-0061-4a3e-9ea1-08070f6fcb26.gif align="center")

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()`).

```swift
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:

```swift
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.

```swift
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.

```swift
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:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1711815673019/017c03c5-4767-4f38-866a-311844b1c435.gif align="center")

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.

```swift
SwiftUIView().frame(minHeight: 40)
```

It looks better now:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1711815695106/82fdb231-f5c1-4574-9950-094dfe0a2263.gif align="center")

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.
    

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

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1711815742595/c13d7738-2890-4e1c-9617-60aaa590360c.gif align="center")

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

You can find example project [here](https://github.com/vitalybatrakov/UIHostingControllerExample).

Thanks for reading and see you in the next posts!
