Introduction: The Reality of Modern iOS Development
If you are an iOS developer today, you likely live in a constant dichotomy. On one hand, you have SwiftUI, the declarative, modern, and shiny framework that Apple promotes at every WWDC. On the other hand, you have reality: a massive codebase, built over years in UIKit, which works, generates revenue, and is simply too large to rewrite from scratch.
The question we all ask isn’t “Should I use SwiftUI?”, but rather “How do I start using SwiftUI without scrapping years of work in UIKit?”.
The good news is that Apple designed SwiftUI with interoperability as a fundamental pillar. You don’t have to choose one or the other. You can (and should) mix them. This strategy, known as “incremental adoption,” allows you to write new screens in SwiftUI while maintaining the legacy core in UIKit.
In this comprehensive tutorial, we are going to break down the magic tool that makes this possible: UIHostingController. You will learn how to present modal views, embed small components inside existing view controllers, and even how to use SwiftUI cells inside an old UITableView.
Part 1: Understanding the Protagonist: UIHostingController
Before writing code, let’s talk architecture. The bridge between these two worlds isn’t black magic; it’s a very specific class called UIHostingController.
What is it?
UIHostingController is, in essence, a wrapper. It is a class that inherits from UIViewController, but its content (its rootView) is a SwiftUI view.
To UIKit, UIHostingController is just another View Controller. You can present it, push it, or add it as a child (addChild). But to SwiftUI, it is the container that manages the lifecycle and rendering.
The anatomy of the bridge:
// In your UIKit code
let swiftUIView = MyModernView()
let controller = UIHostingController(rootView: swiftUIView)Once you have that controller variable, you are back in familiar UIKit territory.
Part 2: Scenario A – Modal Presentation (Simple Navigation)
The simplest use case for starting to integrate SwiftUI is when you create a completely new screen. Imagine your UIKit app needs a “Settings” screen or an “Onboarding” flow. This is the perfect candidate for SwiftUI.
Step 1: Create your SwiftUI View
Let’s assume we have a simple user detail view.
import SwiftUI
struct UserDetailView: View {
var username: String
var onDismiss: () -> Void // We'll talk about this later
var body: some View {
VStack(spacing: 20) {
Image(systemName: "person.circle.fill")
.resizable()
.frame(width: 100, height: 100)
.foregroundColor(.blue)
Text("Hello, \(username)")
.font(.title)
.fontWeight(.bold)
Text("This screen is built 100% in SwiftUI but lives in a UIKit app.")
.multilineTextAlignment(.center)
.padding()
Button(action: {
onDismiss()
}) {
Text("Close")
.bold()
.frame(width: 200, height: 50)
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
}
}
}
}Step 2: Present it from your UIViewController
In your legacy code (e.g., HomeViewController.swift), when the user taps a button, we instantiate the hosting controller and present it.
import UIKit
import SwiftUI // Don't forget to import SwiftUI in the UIKit file
class HomeViewController: UIViewController {
@objc func didTapShowProfile() {
// 1. Configure the SwiftUI View
// We pass a closure to handle the dismiss from within SwiftUI if necessary
let detailView = UserDetailView(username: "Carlos") { [weak self] in
self?.dismiss(animated: true, completion: nil)
}
// 2. Create the Hosting Controller
let hostingController = UIHostingController(rootView: detailView)
// 3. Optional: Configure UIKit modal properties
hostingController.modalPresentationStyle = .pageSheet
if let sheet = hostingController.sheetPresentationController {
sheet.detents = [.medium(), .large()] // iOS 15+ Magic!
}
// 4. Present like any other VC
self.present(hostingController, animated: true)
}
}Why does this work so well? Because you aren’t mixing layouts. The UIHostingController occupies the entire screen (or sheet). UIKit handles the transition, and SwiftUI handles the content. It is the cleanest integration with the lowest risk of visual bugs.
Part 3: Scenario B – Embedding SwiftUI (Hybrid Components)
Here is where things get interesting (and slightly more complex). What if you don’t want a new screen, but rather want to insert a SwiftUI chart inside an existing UIViewController that already contains other UIKit elements?
Here, we must use the View Controller Containment pattern. It is a 4-step ritual that every senior iOS developer knows well.
The Inclusion Ritual
Suppose you have a DashboardViewController and want to add a weather widget made in SwiftUI at the top.
- Add the child:
addChild(hostingController) - Add the view:
view.addSubview(hostingController.view) - Configure Constraints: Use Auto Layout to tell UIKit where that view goes.
- Confirm the move:
hostingController.didMove(toParent: self)
Practical Implementation
class DashboardViewController: UIViewController {
// A container UIView you already placed in the Storyboard or via code
@IBOutlet weak var chartContainerView: UIView!
override func viewDidLoad() {
super.viewDidLoad()
setupSwiftUIChart()
}
private func setupSwiftUIChart() {
// 1. Instantiate the SwiftUI view
let chartView = MarketChartView(data: [10, 20, 15, 30, 25])
// 2. Create the Hosting Controller
let hostingController = UIHostingController(rootView: chartView)
// 3. Add as child to the current VC
addChild(hostingController)
// 4. Add the hosting view to the container
// Important: The hosting view needs to be configured for Auto Layout
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
chartContainerView.addSubview(hostingController.view)
// 5. Configure Constraints (Anchor to the container's edges)
NSLayoutConstraint.activate([
hostingController.view.topAnchor.constraint(equalTo: chartContainerView.topAnchor),
hostingController.view.bottomAnchor.constraint(equalTo: chartContainerView.bottomAnchor),
hostingController.view.leadingAnchor.constraint(equalTo: chartContainerView.leadingAnchor),
hostingController.view.trailingAnchor.constraint(equalTo: chartContainerView.trailingAnchor)
])
// 6. Notify that the transition has finished
hostingController.didMove(toParent: self)
// Pro Tip: Make the background transparent if needed
hostingController.view.backgroundColor = .clear
}
}Performance Considerations: SwiftUI is very efficient, but UIHostingController is not free. Avoid creating hundreds of these controllers if you can avoid it. For long lists, see the next section.
Part 4: Scenario C – SwiftUI in UITableView and UICollectionView
Historically, using SwiftUI inside UIKit cells was a headache regarding performance and cell recycling issues. However, since iOS 16, Apple gifted us UIHostingConfiguration.
If your project supports iOS 16+, forget everything you knew about stuffing controllers into cells. This is the new way to do it.
Using UIHostingConfiguration (The Modern Way)
Imagine you have a classic UITableView and want the cells to be rendered with SwiftUI to take advantage of its layout ease.
class MyTableViewController: UITableViewController {
let data = ["Item 1", "Item 2", "Item 3"]
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
let item = data[indexPath.row]
// iOS 16+ Magic
cell.contentConfiguration = UIHostingConfiguration {
// Inside here, you write pure SwiftUI
HStack {
Image(systemName: "star.fill")
.foregroundColor(.yellow)
VStack(alignment: .leading) {
Text(item)
.font(.headline)
Text("Description generated in SwiftUI")
.font(.caption)
.foregroundColor(.gray)
}
Spacer()
Toggle("", isOn: .constant(true))
}
.padding()
}
return cell
}
}Advantages:
- Automatically handles Self-Sizing. You no longer have to fight with
heightForRowAtand math calculations to know the cell height. SwiftUI calculates its size and tells the table. - It is incredibly clean.
- Automatically manages system margins and separators.
Part 5: Data Communication (The Real Challenge)
Putting a view on the screen is easy. Making that view talk to the rest of your UIKit app is where many developers fail.
We have three main strategies for passing data between UIKit and SwiftUI.
1. Input Data (Initialization)
This is the simplest. You pass the data in the init of the SwiftUI view.
- Type: Unidirectional (UIKit -> SwiftUI).
- Usage: Showing static or initial information.
2. Delegation via Closures
SwiftUI doesn’t use the classic UIKit delegate pattern (though you could force it); it prefers closures.
- Type: Events (SwiftUI -> UIKit).
- Example: A button in SwiftUI that needs to trigger navigation in the parent
UINavigationController.
struct ActionView: View {
var onAction: () -> Void
var body: some View {
Button("Perform Action") {
onAction() // Calls the closure
}
}
}
// In UIKit
let view = ActionView {
print("The SwiftUI button was pressed. UIKit responds.")
self.navigationController?.pushViewController(OtherVC(), animated: true)
}3. State Synchronization (Combine and ObservableObject)
If you need a change in SwiftUI to update a label in UIKit in real-time (or vice versa), you need Combine.
We create an intermediary class that both sides are aware of.
import Combine
// 1. The Shared ViewModel
class SharedViewModel: ObservableObject {
@Published var counter: Int = 0
}
// 2. SwiftUI Side
struct CounterView: View {
@ObservedObject var viewModel: SharedViewModel
var body: some View {
Button("Increment: \(viewModel.counter)") {
viewModel.counter += 1
}
}
}
// 3. UIKit Side
class ViewController: UIViewController {
let viewModel = SharedViewModel()
var cancellables = Set<AnyCancellable>()
@IBOutlet weak var uikitLabel: UILabel!
override func viewDidLoad() {
super.viewDidLoad()
// Bind SwiftUI
let swiftUIView = CounterView(viewModel: viewModel)
let hosting = UIHostingController(rootView: swiftUIView)
// ... add hosting ...
// Listen for changes in UIKit
viewModel.$counter
.receive(on: RunLoop.main)
.sink { [weak self] newValue in
self?.uikitLabel.text = "Value from SwiftUI: \(newValue)"
}
.store(in: &cancellables)
}
}This pattern is powerful because it decouples the view from the logic. UIKit reacts to the data, not the view.
Part 6: Common Pitfalls and How to Fix Them
Integrating two such different UI frameworks is not without its traps. Here are the most common ones I’ve found in production.
1. The “Safe Area” Problem
Sometimes, UIHostingController adds strange insets or double spacing at the top/bottom.
- Solution: You often need to set
hostingController.view.insetsLayoutMarginsFromSafeArea = falsein UIKit, or use.ignoresSafeArea()in the SwiftUI view, depending on who you want controlling the edges.
2. Auto-Sizing (Intrinsic Content)
When you put a UIHostingController inside a container UIView, sometimes UIKit doesn’t know how big the SwiftUI view is, resulting in a view with 0 height.
- Solution: Make sure to anchor all 4 sides (top, bottom, leading, trailing) with constraints.
- Advanced: If you need the
UIHostingControllerto adjust its size to the SwiftUI content (for example, inside a UIKit StackView), enable:
hostingController.view.invalidateIntrinsicContentSize()- And occasionally, you might need a
UIHostingControllersubclass that updates itspreferredContentSize.
3. Navigation Cycles
Avoid using NavigationView or NavigationStack inside a SwiftUI view that is already inside a UIKit UINavigationController. You will end up with two navigation bars (one stacked on top of the other).
- Rule: If UIKit handles the navigation, SwiftUI should only render the content. Let UIKit do the “Push”.
Part 7: Is it worth the effort?
The short answer is: Yes, absolutely.
Integrating SwiftUI into UIKit is not just a technical issue; it’s a team and product strategy issue.
- Iteration Speed: Once the “bridge” is set up, building complex interfaces in SwiftUI is 3x or 4x faster than using Auto Layout and Storyboards.
- Future Proofing: UIKit isn’t disappearing tomorrow, but Apple’s new APIs (Widgets, Live Activities, visionOS) are SwiftUI-first or SwiftUI-only. Having a hybrid architecture prepares you for those platforms.
- Team Morale: Developers want to work with new technologies. Allowing SwiftUI in a legacy project helps retain talent.
Roadmap Summary
- Start Small: Don’t rewrite your
MainViewController. Start with the “Settings” or “About” screen. - Use
UIHostingConfiguration: If you have lists, it’s the easiest place to start in iOS 16+. - Respect Data Flow: Use
Combineor Closures to keep state synchronized; don’t try to hack direct references.
SwiftUI and UIKit can be best friends. They just need a good UIHostingController to introduce them.
Additional Resources
- Apple Documentation: Interfacing with UIKit.
- WWDC Videos: “Use SwiftUI with UIKit” (search for the ones from the last 3 years to see the evolution).
If you have any questions about this article, please contact me and I will be happy to help you 🙂. You can contact me on my X profile or on my Instagram profile.