Subclassing UIHostingController to Bridge the SwiftUI 1 to SwiftUI 2 Divide
Apple introduced SwiftUI at WWDC19 as a new, declarative UI framework that makes it fast and easy for developers to write UI code compatible with all of Apple’s major devices and respective operating systems.
I was recently tasked with developing a new iOS app from scratch. This meant that I’d also need to decide whether to build with a UIKit-first or SwiftUI-first approach. At the time work started on the project, iOS 14 was just rolling out to the public, meaning that I’d need to support both iOS 13 and 14.
As a framework, SwiftUI is still in its infancy. The original SwiftUI was nowhere near a complete, drop-in replacement for UIKit. While SwiftUI 2 advances the framework much closer to UIKit parity, there are several situations where SwiftUI 1 did not offer a feature required to implement the app’s design requirements that would have otherwise been available in UIKit or SwiftUI 2.
In many situations, it was easy enough to drop in an if #available(iOS 14.0, *) { }
into my view body to render a separate view implementation for iOS 14 vs. 13, but this wasn’t a viable approach for everything.
One tool that I found extremely helpful in bridging the gap between UIKit, SwiftUI 1, and SwiftUI 2 is UIHostingController. This post focuses on a few uses for UIHostingController that have saved me a lot of time and headaches over the past several months.
What is UIHostingController?
If you’re new to SwiftUI, you might be wondering what the deal is with UIHostingController, UIViewRepresentable, UIViewControllerRepresentable, and other framework classes introduced alongside SwiftUI. In a nutshell, these classes exist to help bridge the gap between UIKIt and SwiftUI, allowing developers to embed SwiftUI Views in UIKit view hierarchies, and vice versa.
When starting a new SwiftUI project in Xcode 12, there’s an option to select either the traditional UIKit Lifecycle and App Delegate or the new SwiftUI App Lifecycle. For this project, I chose the UIKit Lifecycle since I needed to support iOS 13, and the ‘App’ protocol used to facilitate the SwiftUI App Lifecycle is only available for apps with a minimum deployment target of iOS 14.
In this project configuration, the Scene Delegate instantiates a plain UIHostingController object and sets it as the .rootViewController of the window. This root UIHostingController, if you will, is responsible for presenting the initial SwiftUI view hierarchy of the application.
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
// Create the SwiftUI view that provides the window contents.
let contentView = ContentView()
// Use a UIHostingController as window root view controller.
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
}
}
[...]
}
Subclassing UIHostingController
This setup seems pretty straightforward, and I’d expected to simply use this UIHostingController to present my SwiftUI view hierarchy. From there, I’d anticipated using SwiftUI to handle the rest of my app’s lifecycle, view presentation, etc.
It turns out that things got a little more complicated, as they so often do.
There are certain UI conventions made simple in SwiftUI 2 that are not implemented or available in SwiftUI 1, such as:.
- Propagating an AlertController as the result of a failed network request or change in network connection status
- Presenting a full-screen modal view
- Handling interface orientation, laying out subviews, and other miscellaneous tasks normally handled by a UIViewController
In the end, I found myself using a subclass of UIHostingController to perform these simple tasks.
Base Subclass Implementation
The base subclass imports Combine and sets up an array to hold any subscriptions. I’d like to note that using Combine to subscribe to notifications is not strictly necessary, but it does make responding to notifications a bit more convenient than the traditional KVO method of doing so.
import Foundation
import SwiftUI
import Combine
class CustomUIHostingController<Content>: UIHostingController<Content> where Content: View {
/// references to event subscribers
private var subscribers = [AnyCancellable]()
override init(rootView: Content) {
super.init(rootView: rootView)
// subscriptions to notifications will go here
}
/// required initializer
@objc required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
In SceneDelegate, replace UIHostingController with this subclass:
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
// Create the SwiftUI view that provides the window contents.
let contentView = ContentView()
// Use a UIHostingController as window root view controller.
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = CustomUIHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
}
}
[...]
}
Propagating UIAlerts from UIHostingController
SwiftUI does provide a mechanism to present an Alert declaratively, but there are some inherent limitations with this approach. First, the Alert API in SwiftUI is not nearly as customizable as UIAlert. Second, Alert presentation is declared within the view body and bound to a SwiftUI @State variable, like so:
import SwiftUI
struct ContentView: View {
@State private var presentAlert = false
var body: some View {
Button(action: {
presentAlert.toggle()
}, label: {
Text("Present Alert")
})
.alert(isPresented: $presentAlert, content: {
Alert(title: Text("Alert"),
message: Text("It's an alert"),
primaryButton: .default(Text("Dismiss")),
secondaryButton: .cancel())
})
}
}>
This approach works very well if the alert needs to be presented as the result of direct UI interaction, such as pressing a button.
Let’s say, though, that you want to present an alert if a network request fails, or a long-running task that was initiated from a different view within the app completes. Theoretically, these kinds of alerts may need to be presented at any time, while any given view is presented. It wouldn’t make sense to write all of our separate views to respond to any potential alert that may or may not be propagated.
Enter UIHosting Controller. We can subscribe to any Notification posted to a NotificationCenter and present a UIAlert accordingly.
import Foundation
import SwiftUI
import Combine
class CustomUIHostingController<Content>: UIHostingController<Content> where Content: View {
/// references to event subscribers
private var subscribers = [AnyCancellable]()
private var reachabilityService: ReachabilityService()
override init(rootView: Content) {
super.init(rootView: rootView)
subscribeToNetworkErrors()
}
private func subscribeToNetworkErrors() {
let networkErrorSub = NotificationCenter.default.publisher(for: Notification.Name.init("networkErrorAlert")).sink { _ in
let alertController = UIAlertController.init(title: "Network Error",
message: "There was an error retrieving data.",
preferredStyle: .alert)
let dismissAction = UIAlertAction.init(title: "Dismiss", style: .default, handler: nil)
alertController.addAction(dismissAction)
self.present(alertController, animated: true, completion: nil)
}
let networkConnectivitySub = NotificationCenter.default.publisher(for: NSNotification.Name.init("networkConnectivityAlert")).sink { _ in
let alertController = UIAlertController.init(title: "Network Error",
message: "No active internet connection",
preferredStyle: .alert)
let dismissAction = UIAlertAction.init(title: "Dismiss", style: .default, handler: nil)
alertController.addAction(dismissAction)
self.present(alertController, animated: true, completion: nil)
}
subscribers.append(contentsOf: [networkErrorSub, networkConnectivitySub])
}
/// required initializer
@objc required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
Of significance here is the call to self.present(_:animated:completion)
. That’s right, UIHostingController inherits directly from UIViewController! How incredibly useful. Knowing that we have the full power of UIViewController available to us within our UIHostingController subclass opens up a host of new and exciting possibilities.
Presenting Full-Screen Modal Views
Presenting views modally is an extremely common navigation pattern in iOS applications. This is why it’s a little perplexing that SwiftUI 1 only allows us to present views modally in the ‘sheet’ presentation style. SwiftUI 2 offers more options, including full screen modal presentation, but I was a bit blindsided by this limitation and needed to find a way to support full-screen modal view presentation on iOS 13 devices.
By once again leveraging the power of NotificationCenter, I was able to pass a SwiftUI View in the userInfo property of Notification, and then wrap said view in its own UIHostingController and present it modally.
Posting a notification from within a view body:
import SwiftUI
struct ContentView: View {
var body: some View {
Button(action: {
NotificationCenter.default.post(name: Notification.Name("fullScreenModalPresent"),
object: self,
userInfo: ["view": Text("show me full screen!")])
}, label: {
Text("Present Alert")
})
}
}
Responding to the notification in our CustomUIHostingController:
import Foundation
import SwiftUI
import Combine
class CustomUIHostingController: UIHostingController where Content: View {
/// references to event subscribers
private var subscribers = [AnyCancellable]()
private var reachabilityService: ReachabilityService()
override init(rootView: Content) {
super.init(rootView: rootView)
subscribeToNetworkErrors()
setUpModalSubscription()
}
private func setUpModalSubscription() {
let fullModalSub = NotificationCenter.default.publisher(for: Notification.Name.init("fullScreenModalPresent")).sink { notification in
if let view = notification.userInfo?["view"] as? AnyView {
let controller = UIHostingController(rootView: view)
controller.modalPresentationStyle = .fullScreen
self.present(controller, animated: true, completion: nil)
}
}
}
[...]
}
Handling Other UIViewController Responsibilities
There were a few instances where I ended up using UIKit controls wrapped as UIViewRepresentable objects. To learn more about what that entails, I’d recommend checking out this Apple tutorial. The basic premise, though, is that UIViewRepresentable creates an instance of a UIView that can be embedded directly within a SwiftUI view hierarchy.
In my case, I needed a UICollectionView to show a grid of images. This is another case where SwiftUI 2 offers a solution - LazyVGrid and LazyHGrid. But again, these views were unavailable to me on iOS 13.
As part of my CollectionView layout, I needed to be able to invalidate my collection view layout on UIViewController’s viewWillLayout subviews. Since my CollectionView UIViewRepresentable was part of the main view hierarchy presented by my CustomUIHostingController, I was able to take the following approach:
class CustomUIHostingController<Content>: UIHostingController<Content> where Content: View {
[...]
override func viewWillLayoutSubviews() {
let notification = Notification(name: Notification.Name.init("viewWillLayoutSubviewsNotification"))
NotificationCenter.default.post(notification)
super.viewWillLayoutSubviews()
}
[...]
}
By overriding viewWillLayoutSubviews, I was able to post a notification that my CollectionView could subscribe to and invalidate the layout accordingly.
Summary
These are just a few examples of ways to use UIHostingController to help bridge the gap between SwiftUI 1 and SwiftUI 2. There are likely other ways to tackle these issues, but this approach has worked well for me. There are many other opportunities to harness UIHostingController to this effect. Have fun, get creative and happy coding!