The Apple development ecosystem has experienced a massive revolution in recent years. As an iOS Developer, it is very likely that you have found yourself at the crossroads between maintaining a legacy codebase and the desire to adopt the new technologies that Apple introduces every year. The leap in Swift programming has never been as exciting as it is now.
SwiftUI has changed the way we build user interfaces, offering a declarative syntax that drastically reduces lines of code and common errors. However, rewriting a complete application from scratch is rarely a viable option for companies. This is where one of the most valuable skills today comes in: knowing exactly how to integrate SwiftUI into UIKit.
In this extensive tutorial, we will deeply explore how you can combine the best of both worlds. Using Swift and Xcode, we will learn how to inject modern views into your existing applications, not only on iOS, but also expanding our horizons to macOS and watchOS.
1. The Bridge Between Two Worlds: The HostingController Family
The fundamental secret to embedding SwiftUI in the classic imperative frameworks (UIKit, AppKit, and WatchKit) lies in a special family of controllers provided by Apple. These controllers act as native wrappers that translate the declarative view hierarchy of SwiftUI into something the classic system can understand and render.
Depending on the platform you are working on within Xcode, you will use a different controller:
- iOS / tvOS:
UIHostingController(inherits fromUIViewController). - macOS:
NSHostingController(inherits fromNSViewController). - watchOS:
WKHostingController(inherits fromWKInterfaceController).
By inheriting from the standard base classes of each platform, these hosting controllers can be introduced into your existing navigation hierarchy (like a UINavigationController or presented as a modal) exactly like any other controller in your application.
2. Integration in iOS: Injecting SwiftUI into UIKit
Let’s dive into the code. Imagine you have a traditional iOS application and your design team has created a spectacular new user profile screen. Instead of dealing with Auto Layout and UITableView, you decide to build it in SwiftUI.
Step 2.1: Creating the View in SwiftUI
First, we create our declarative view in Swift. Open your project in Xcode and create a new SwiftUI View file.
import SwiftUI
struct UserProfileView: View {
var username: String
var bio: String
var body: some View {
VStack(spacing: 20) {
Image(systemName: "person.circle.fill")
.resizable()
.frame(width: 100, height: 100)
.foregroundColor(.blue)
Text(username)
.font(.largeTitle)
.fontWeight(.bold)
Text(bio)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding()
Button(action: {
print("Profile edited")
}) {
Text("Edit Profile")
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
}
.padding(.horizontal)
Spacer()
}
.padding()
}
}
Step 2.2: Presenting Programmatically
If your architecture in Swift programming is based on initializing views programmatically (without Storyboards), the process is extremely straightforward. We need to instantiate a UIHostingController and pass our UserProfileView as the root view.
From any existing UIViewController, you can do the following:
import UIKit
import SwiftUI
class HomeViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
setupNavigateButton()
}
private func setupNavigateButton() {
let button = UIButton(type: .system)
button.setTitle("View Profile (SwiftUI)", for: .normal)
button.addTarget(self, action: #selector(showProfile), for: .touchUpInside)
button.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(button)
NSLayoutConstraint.activate([
button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
button.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
@objc private func showProfile() {
// 1. Instantiate the SwiftUI view
let swiftUIView = UserProfileView(username: "AppleFan99", bio: "Passionate iOS Developer creating the future.")
// 2. Wrap the view in a UIHostingController
let hostingController = UIHostingController(rootView: swiftUIView)
// 3. Present the controller as we would with any UIViewController
navigationController?.pushViewController(hostingController, animated: true)
// Alternatively, to present modally:
// present(hostingController, animated: true)
}
}
Step 2.3: Integration using Storyboards / Interface Builder
If your current project relies heavily on Storyboards, Xcode makes the job much easier. Apple introduced the Hosting Controller directly into the Object Library of Interface Builder.
- Open your
Main.storyboardfile. - Press
Cmd + Shift + Lto open the library. - Search for Hosting Controller and drag it to your canvas.
- Create a Segue from your source controller to this new
Hosting Controller. - To inject the view, we will use an
IBSegueAction.
In your source UIViewController, add the following code:
import UIKit
import SwiftUI
class DashboardViewController: UIViewController {
// This function is linked directly from the Storyboard
@IBSegueAction func showSwiftUIProfile(_ coder: NSCoder) -> UIViewController? {
let profileView = UserProfileView(username: "StoryboardUser", bio: "Integrating SwiftUI visually.")
return UIHostingController(coder: coder, rootView: profileView)
}
}
Note: Drag from the segue in the Storyboard to the code of your controller to connect this action.
3. Two-Way Communication: Data and Delegates
Knowing how to integrate SwiftUI into UIKit is only the first step. The real challenge for an iOS Developer is getting both technologies to communicate smoothly. What happens if a button in SwiftUI needs to update a label in UIKit, or if UIKit downloads data from the internet that SwiftUI needs to show?
From UIKit to SwiftUI: ObservableObject
To pass dynamic data from the imperative world to the declarative one, the best tool in Swift is the ObservableObject protocol along with the Combine framework.
We create a View Model (ViewModel):
import Foundation
import Combine
class UserViewModel: ObservableObject {
@Published var followerCount: Int = 0
func fetchFollowers() {
// Simulate a network call
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.followerCount = Int.random(in: 100...5000)
}
}
}
We modify our SwiftUI view to react to this model:
import SwiftUI
struct StatsView: View {
@ObservedObject var viewModel: UserViewModel
var body: some View {
VStack {
Text("Followers")
.font(.headline)
Text("\(viewModel.followerCount)")
.font(.system(size: 50, weight: .bold))
.foregroundColor(.green)
}
}
}
In our UIKit controller, we keep the reference of the ViewModel and update the data when necessary. SwiftUI will redraw automatically.
class StatsViewController: UIViewController {
var viewModel = UserViewModel()
override func viewDidLoad() {
super.viewDidLoad()
let swiftUIView = StatsView(viewModel: viewModel)
let hostingController = UIHostingController(rootView: swiftUIView)
// Add the hosting controller as a child
addChild(hostingController)
view.addSubview(hostingController.view)
hostingController.view.frame = view.bounds
hostingController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
hostingController.didMove(toParent: self)
// Start downloading data from UIKit
viewModel.fetchFollowers()
}
}
From SwiftUI to UIKit: Closures and Delegates
If you need to notify UIKit about a user action in SwiftUI (for example, tapping a “Close” or “Save” button), closures (code blocks) are the most “Swift-friendly” way to achieve this.
import SwiftUI
struct ActionView: View {
// Closure that will be implemented by UIKit
var onDismiss: (() -> Void)?
var body: some View {
Button(action: {
// Execute the closure when the button is tapped
onDismiss?()
}) {
Text("Close Screen")
.foregroundColor(.red)
}
}
}
In UIKit:
class PresenterViewController: UIViewController {
func presentActionView() {
var actionView = ActionView()
// Define the behavior of the closure
actionView.onDismiss = { [weak self] in
self?.dismiss(animated: true, completion: nil)
}
let hostingController = UIHostingController(rootView: actionView)
present(hostingController, animated: true)
}
}
4. Expanding Horizons: Integration in macOS (AppKit)
The power of modern Swift programming is that the declarative paradigm is cross-platform. If you are developing for Mac computers using Xcode, the traditional framework is not UIKit, but AppKit.
To integrate SwiftUI into a legacy macOS application, the process is conceptually identical to iOS, but we use NSHostingController or NSHostingView.
Using NSHostingView
Unlike iOS, in AppKit it is very common to work directly at the view level rather than relying solely on View Controllers. NSHostingView allows us to embed SwiftUI directly into an NSView hierarchy.
import Cocoa
import SwiftUI
class MacSettingsViewController: NSViewController {
override func viewDidLoad() {
super.viewDidLoad()
// 1. Create the SwiftUI view
let settingsSwiftUIView = Text("System Preferences (SwiftUI)")
.font(.title)
.padding()
// 2. Create the NSHostingView
let hostingView = NSHostingView(rootView: settingsSwiftUIView)
hostingView.translatesAutoresizingMaskIntoConstraints = false
// 3. Add it to the main AppKit view
self.view.addSubview(hostingView)
// 4. Configure constraints
NSLayoutConstraint.activate([
hostingView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
hostingView.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
}
Using NSHostingController
If you prefer to work at the window/view controller level in macOS:
import AppKit
import SwiftUI
func openSwiftUIWindow() {
let mySwiftUIView = UserProfileView(username: "MacUser", bio: "macOS Development")
let hostingController = NSHostingController(rootView: mySwiftUIView)
let window = NSWindow(contentViewController: hostingController)
window.title = "SwiftUI Window in AppKit"
window.makeKeyAndOrderFront(nil)
}
5. The Smartwatch: Integration in watchOS (WatchKit)
Development on watchOS was one of the first to adopt SwiftUI natively. In fact, Apple highly recommends that all new watchOS applications be built purely with this declarative framework. However, if as an iOS Developer you must maintain an older application that uses WatchKit Storyboards (WKInterfaceController), there is also a bridge.
We use WKHostingController. It works slightly differently than its counterparts in iOS and macOS, since WatchKit controllers do not have the same flexible view hierarchies.
import WatchKit
import Foundation
import SwiftUI
// Your modern SwiftUI view
struct HeartRateView: View {
var bpm: Int
var body: some View {
VStack {
Image(systemName: "heart.fill")
.foregroundColor(.red)
.font(.largeTitle)
Text("\(bpm) BPM")
.font(.title2)
}
}
}
// The classic WatchKit controller hosting the view
class HeartRateHostingController: WKHostingController<HeartRateView> {
// You must override this computed property to return your SwiftUI view
override var body: HeartRateView {
return HeartRateView(bpm: 72)
}
override func awake(withContext context: Any?) {
super.awake(withContext: context)
// Additional initial configuration
}
}
In WatchKit, you need to specify the generic view type (<HeartRateView>) in the class declaration and provide the instance in the body property.
6. Best Practices and Performance for the iOS Developer
Learning the syntax of how to integrate SwiftUI into UIKit is relatively quick, but mastering the architecture requires experience. Here are several key recommendations when combining these frameworks in Xcode:
Lifecycle Management
Declarative views do not have an exact lifecycle like viewDidLoad or viewWillAppear. They have modifiers like .onAppear and .onDisappear.
- Common problem: If you trigger heavy network calls in
.onAppearinside a cell of a mixed UIKit/SwiftUI list, you could saturate the main thread because.onAppearmight be called multiple times during scrolling. - Solution: Coordinate network calls from your parent
UIViewControllerand simply inject the resulting data via an@ObservedObjector through state passed to the embedded view.
Intrinsic Content Size
When you add a UIHostingController inside a layout based on Auto Layout, you often want the UIKit view to adapt to the exact size of your declarative view’s content.
Starting from iOS 16, Apple improved the sizingOptions property on the hosting controller.
let hostingController = UIHostingController(rootView: myView)
// Allows the UIKit view to automatically size based on SwiftUI content
hostingController.sizingOptions = .intrinsicContentSize
Limit Bridge Points
Although it is technically possible to have a UIViewController containing a UIHostingController, which in turn has a UIViewRepresentable to show a classic UIKit UILabel, do not do it.
The context switch between the imperative Core Animation rendering system and the declarative state engine comes with a small performance cost.
- Golden rule: Try to make the “bridge” happen only at the “Full Screen” level or “Full Component” level (like a complete complex table cell). Avoid deep, nested embeddings like Russian dolls.
Beware of Retain Cycles in Closures
As we saw in the two-way communication section, when passing closures from your UIViewController to your SwiftUI views, always make sure to capture [weak self] or [unowned self] if the closure references properties of the parent controller. Since the parent controller retains the UIHostingController, which retains the SwiftUI view (where the closure lives), it is very easy to create a retain cycle (Memory Leak) in Swift programming.
Final Summary
The path of the modern iOS Developer requires adaptability. Knowing how to integrate SwiftUI into UIKit (and its AppKit and WatchKit counterparts) gives you a superpower: the ability to modernize applications iteratively, one screen at a time, without the risk and cost of rewriting an entire project from scratch.
Xcode has matured enough for this coexistence to be robust and efficient. We have seen that the master key for this workflow in Swift programming are the hosting controllers (UIHostingController, NSHostingController, and WKHostingController). By combining these tools with proper data flow management using ObservableObject and Combine, you can create flawless, hybrid user experiences across the entire Swift ecosystem.