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 thehostSwiftUIView
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 thepreferredContentSize
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!