Embedding SwiftUI in UIKit is not a temporary workaround. For a mature application, it is often the safest architecture for adopting SwiftUI: UIKit keeps ownership of navigation and established flows, while new components use SwiftUI where its declarative model pays off.
The decision becomes much easier once you stop treating UIHostingController and UIHostingConfiguration as interchangeable wrappers. A hosting controller is a real view controller. It participates in containment, presentation, safe-area handling, and UIKit lifecycle events. A hosting configuration is cell content. It participates in table or collection view reuse, configuration state, margins, and self-sizing.
This guide builds both approaches, then connects them to state ownership, layout, navigation, performance, and testing. The goal is not merely to make a SwiftUI view appear. It is to make the boundary predictable enough to survive production changes.
1. Choose the Boundary Before Writing the Wrapper
Start by asking what UIKit concept the SwiftUI content is replacing.
Use UIHostingController when the content is:
- a full screen pushed onto a navigation controller;
- a sheet, popover, or custom presentation;
- a substantial region owned by a parent view controller;
- expected to react to controller lifecycle or safe-area changes;
- required on iOS 13 through iOS 15.
Use UIHostingConfiguration when the content is:
- the content of a
UITableViewCellorUICollectionViewCell; - created repeatedly from diffable data-source items;
- expected to self-size and follow cell configuration state;
- deployed on iOS 16 or later.
Apple describes UIHostingController as the UIKit view controller that manages a SwiftUI hierarchy. UIHostingConfiguration instead conforms to UIContentConfiguration, which is why it fits the modern cell pipeline without inventing a controller per row.
This is an ownership decision, not a syntax decision. A screen needs controller semantics. A row needs reuse semantics.
2. Embed a SwiftUI Screen with UIHostingController
Imagine an existing UIKit catalog. UIKit owns the navigation controller, but the product detail screen is new SwiftUI work. Keep navigation at the boundary by giving the SwiftUI view intent closures instead of a reference to UINavigationController.
import SwiftUI
import UIKit
struct Product: Identifiable, Equatable {
let id: UUID
let name: String
let price: Decimal
let isFavorite: Bool
}
struct ProductDetailView: View {
let product: Product
let onToggleFavorite: () -> Void
let onBuy: () -> Void
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
Text(product.name)
.font(.largeTitle.bold())
Text(product.price, format: .currency(code: "USD"))
.font(.title2)
Button(
product.isFavorite ? "Remove from Favorites" : "Add to Favorites",
systemImage: product.isFavorite ? "heart.fill" : "heart",
action: onToggleFavorite
)
Button("Buy Now", action: onBuy)
.buttonStyle(.borderedProminent)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
.navigationTitle("Product")
.navigationBarTitleDisplayMode(.inline)
}
}
@MainActor
final class CatalogViewController: UIViewController {
private var product = Product(
id: UUID(),
name: "Field Notes Case",
price: 49,
isFavorite: false
)
func showProduct() {
let controller = makeProductController()
navigationController?.pushViewController(controller, animated: true)
}
private func makeProductController() -> UIViewController {
UIHostingController(
rootView: ProductDetailView(
product: product,
onToggleFavorite: { [weak self] in
self?.toggleFavorite()
},
onBuy: { [weak self] in
self?.showCheckout()
}
)
)
}
private func toggleFavorite() {
product = Product(
id: product.id,
name: product.name,
price: product.price,
isFavorite: !product.isFavorite
)
// Persist through the existing UIKit-owned service or use case.
}
private func showCheckout() {
let checkout = CheckoutViewController(productID: product.id)
navigationController?.pushViewController(checkout, animated: true)
}
}
This example deliberately leaves one flaw visible: ProductDetailView receives a value snapshot. Toggling the UIKit property will not redraw the already-created SwiftUI root view. That can be correct for immutable detail content, but interactive shared state needs an explicit owner.
For architecture around use cases and dependency direction, the existing MVVM with Clean Architecture guide provides a useful layer beneath this UI boundary.
3. Share State with Modern Observation
On iOS 17 and later, the Observation framework makes a reference model a clean bridge. UIKit can construct and retain the model; SwiftUI observes the properties it reads.
import Observation
import SwiftUI
import UIKit
@MainActor
@Observable
final class ProductDetailModel {
private(set) var product: Product
private(set) var isBuying = false
var errorMessage: String?
private let purchase: (UUID) async throws -> Void
init(
product: Product,
purchase: @escaping (UUID) async throws -> Void
) {
self.product = product
self.purchase = purchase
}
func toggleFavorite() {
product = Product(
id: product.id,
name: product.name,
price: product.price,
isFavorite: !product.isFavorite
)
}
func buy() async -> Bool {
guard !isBuying else { return false }
isBuying = true
errorMessage = nil
defer { isBuying = false }
do {
try await purchase(product.id)
return true
} catch {
errorMessage = "The purchase could not be completed."
return false
}
}
}
struct ObservedProductDetailView: View {
@Bindable var model: ProductDetailModel
let onFinished: () -> Void
var body: some View {
Form {
Section {
Text(model.product.name)
Text(model.product.price, format: .currency(code: "USD"))
}
Button(model.product.isFavorite ? "Unfavorite" : "Favorite") {
model.toggleFavorite()
}
Button("Buy") {
Task {
if await model.buy() {
onFinished()
}
}
}
.disabled(model.isBuying)
}
.alert(
"Purchase Failed",
isPresented: Binding(
get: { model.errorMessage != nil },
set: { if !$0 { model.errorMessage = nil } }
)
) {
Button("OK", role: .cancel) {}
} message: {
Text(model.errorMessage ?? "")
}
}
}
UIKit keeps the model alive and owns the navigation response:
@MainActor
final class ProductRoute {
private let model: ProductDetailModel
private weak var navigationController: UINavigationController?
init(product: Product, navigationController: UINavigationController) {
self.navigationController = navigationController
self.model = ProductDetailModel(product: product) { productID in
try await PurchaseService.shared.purchase(productID: productID)
}
}
func start() {
guard let navigationController else { return }
let root = ObservedProductDetailView(model: model) { [weak navigationController] in
navigationController?.popViewController(animated: true)
}
let host = UIHostingController(rootView: root)
navigationController.pushViewController(host, animated: true)
}
}
Marking UI state @MainActor makes the isolation contract visible. Observation tells SwiftUI when a read property changes; it does not move work off the main actor and it does not automatically update UIKit controls. UIKit views that display the same model still need a deliberate callback, reload, or observation registration.
If the deployment target predates iOS 17, use ObservableObject and @Published, or keep value state plus explicit root-view replacement. The detailed tradeoffs are covered in Swift Observation and @Observable state management.
4. Contain the Hosting Controller Correctly
Pushing or presenting a hosting controller requires no unusual setup. Embedding its view inside another controller does. Follow the complete UIKit containment sequence:
@MainActor
func install<Content: View>(
_ content: Content,
in container: UIView,
parent: UIViewController
) -> UIHostingController<Content> {
let host = UIHostingController(rootView: content)
host.view.backgroundColor = .clear
parent.addChild(host)
container.addSubview(host.view)
host.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
host.view.leadingAnchor.constraint(equalTo: container.leadingAnchor),
host.view.trailingAnchor.constraint(equalTo: container.trailingAnchor),
host.view.topAnchor.constraint(equalTo: container.topAnchor),
host.view.bottomAnchor.constraint(equalTo: container.bottomAnchor)
])
host.didMove(toParent: parent)
return host
}
Retain the returned controller for as long as the region exists. On removal, call willMove(toParent: nil), remove its view, then call removeFromParent(). Adding only host.view skips controller containment and can produce confusing appearance, rotation, and environment behavior.
Also decide which framework owns navigation. A UIKit-owned flow should expose closures or a small routing protocol to SwiftUI. Do not let a leaf SwiftUI view search the responder chain for a navigation controller. Conversely, once a feature is fully SwiftUI-owned, keep its internal destinations in NavigationStack and notify UIKit only when the whole feature finishes. The SwiftUI coordinator pattern guide explores that larger flow boundary.
5. Make Sizing an Explicit Contract
Most hosting bugs blamed on SwiftUI are actually ambiguous UIKit constraints. For a full-screen or edge-pinned child, constrain the hosting view exactly like any other UIKit view and let the proposed size flow into SwiftUI.
Content-sized hosts require more care. On iOS 16 and later, UIHostingController.sizingOptions can propagate SwiftUI’s ideal-size changes:
let host = UIHostingController(rootView: StatusBanner(message: "Saved"))
host.sizingOptions = [.intrinsicContentSize]
Use .intrinsicContentSize when Auto Layout needs the hosted view’s intrinsic size. Use .preferredContentSize for a container or presentation controller that reads the child controller’s preferred size. Apple notes that preferred-content measurement has a performance cost because the system asks SwiftUI for its ideal size using an unspecified proposal. Do not enable both options reflexively across a scrolling screen.
For a one-off measurement, sizeThatFits(in:) proposes a bound directly:
let target = host.sizeThatFits(
in: CGSize(width: availableWidth, height: .greatestFiniteMagnitude)
)
Do not combine an unconstrained hosting view, an infinitely flexible SwiftUI root, and an expectation of intrinsic height. Give at least one side of the boundary a definite proposal. Validate Dynamic Type, long localized strings, rotation, split view, and accessibility content sizes; a fixed height that works in English at .large is not a sizing strategy.
Safe areas are another contract. By default the hosting controller contributes all safe-area regions. Prefer SwiftUI modifiers such as safeAreaInset or ignoresSafeArea for view-specific behavior. Newer SDKs also expose safeAreaRegions, but changing controller-wide safe-area behavior should be reserved for a container whose layout rules you fully own.
6. Build Reusable Cells with UIHostingConfiguration
For iOS 16 and later, a hosting configuration is the natural way to put SwiftUI in a UIKit list. This diffable data-source example passes stable values and sends actions back through a store.
import SwiftUI
import UIKit
struct ProductRow: View {
let product: Product
let onFavorite: () -> Void
var body: some View {
HStack(spacing: 12) {
Image(systemName: "shippingbox")
.frame(width: 32, height: 32)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 3) {
Text(product.name).font(.headline)
Text(product.price, format: .currency(code: "USD"))
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Button(action: onFavorite) {
Image(systemName: product.isFavorite ? "heart.fill" : "heart")
}
.buttonStyle(.plain)
.accessibilityLabel(product.isFavorite ? "Remove favorite" : "Add favorite")
}
}
}
@MainActor
final class ProductListViewController: UICollectionViewController {
typealias DataSource = UICollectionViewDiffableDataSource<Int, UUID>
private var productsByID: [UUID: Product] = [:]
private lazy var cellRegistration = UICollectionView.CellRegistration<
UICollectionViewCell,
Product
> { [weak self] cell, _, product in
cell.contentConfiguration = UIHostingConfiguration {
ProductRow(product: product) { [weak self] in
self?.toggleFavorite(id: product.id)
}
}
.margins(.all, 12)
}
private lazy var dataSource = makeDataSource()
private func makeDataSource() -> DataSource {
DataSource(collectionView: collectionView) { [weak self] collectionView, indexPath, id in
guard
let self,
let product = self.productsByID[id]
else { return nil }
return collectionView.dequeueConfiguredReusableCell(
using: self.cellRegistration,
for: indexPath,
item: product
)
}
}
private func toggleFavorite(id: UUID) {
guard let product = productsByID[id] else { return }
productsByID[id] = Product(
id: product.id,
name: product.name,
price: product.price,
isFavorite: !product.isFavorite
)
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems([id])
dataSource.apply(snapshot, animatingDifferences: true)
}
}
The stored CellRegistration is created once and reused. This keeps cell creation predictable and prevents accidental variation between dequeues.
UIHostingConfiguration handles self-sizing and supports content margins, minimum size, and a SwiftUI background. In list layouts it can bridge behaviors such as swipe actions and separator alignment. It does not turn the entire collection view into SwiftUI: UIKit still owns item identity, prefetching, selection, focus, and snapshot application.
State and reuse
A cell is not a state owner. Its SwiftUI hierarchy can disappear and be recreated as content configurations change. Keep business state in the screen’s store, model, or data source; pass a value for the current item; and identify actions with the item’s stable ID.
Avoid capturing the cell or index path in a button closure. Index paths change when snapshots move items, and capturing cells can extend their lifetime. Resolve actions through the item identifier. After the model changes, reconfigure that item rather than relying on local @State to remain attached to a reused row.
7. Avoid Performance and Lifecycle Traps
Hosting introduces a framework boundary, not a free performance optimization. The most common problems are architectural:
- Recreating a screen host on every update. Retain the controller or shared model. Replace
rootViewintentionally when using immutable snapshots; do not rebuild the whole controller during every UIKit layout pass. - Doing work in
body. SwiftUI may evaluatebodyfrequently. Move image decoding, formatting caches, database access, and expensive transformations outside the view. - Using unstable identity in cells. Random IDs make diffable snapshots and SwiftUI both treat old content as new. Use domain identifiers.
- Starting duplicate tasks. A SwiftUI
.taskcan restart when identity or hierarchy changes. Make loads cancellable and idempotent, or let a longer-lived model own them. - Creating retain cycles at the boundary. Hosting controllers retain root views, and root views retain their closure values. UIKit owners captured by those closures should usually be weak.
- Enabling dynamic ideal-size tracking everywhere.
sizingOptionscan trigger additional measurement. Use it only where UIKit actually consumes the result.
Profile the integrated screen, not an isolated preview. Scroll rapidly, change Dynamic Type, apply large snapshots, and inspect the Time Profiler and Allocations instruments. If work blocks interaction, the principles in why async Swift code can still freeze the UI apply equally to a hosted hierarchy.
8. Test Both Sides of the Boundary
Keep most behavior out of the wrapper. The model can be tested directly with Swift Testing, including async state transitions:
import Testing
@MainActor
struct ProductDetailModelTests {
@Test
func failedPurchaseCanBeRetriedSuccessfully() async {
struct PurchaseError: Error {}
let product = Product(
id: UUID(),
name: "Case",
price: 49,
isFavorite: false
)
var shouldFail = true
var purchasedID: UUID?
let model = ProductDetailModel(product: product) { id in
if shouldFail {
throw PurchaseError()
}
purchasedID = id
}
let firstAttemptSucceeded = await model.buy()
#expect(firstAttemptSucceeded == false)
#expect(model.isBuying == false)
#expect(model.errorMessage != nil)
shouldFail = false
let retrySucceeded = await model.buy()
#expect(retrySucceeded == true)
#expect(purchasedID == product.id)
#expect(model.isBuying == false)
#expect(model.errorMessage == nil)
}
}
Then add focused integration coverage:
- instantiate the hosting controller and force
loadViewIfNeeded(); - assert UIKit containment and navigation callbacks;
- run snapshot tests at representative widths, color schemes, and content sizes;
- exercise collection reuse by scrolling items off-screen and reconfiguring them;
- use UI tests for the critical handoff, such as a SwiftUI button pushing a UIKit checkout.
Do not assert SwiftUI’s private view hierarchy from UIKit. Treat the hosted root as a component with inputs, rendered output, and actions. That boundary produces tests that survive framework implementation changes.
9. Use a Migration Decision Guide
Choose the smallest seam that produces real product value.
| Situation | Recommended approach |
|---|---|
| New independent screen in a UIKit flow | Push or present a UIHostingController |
| SwiftUI panel inside an existing UIKit screen | Child UIHostingController with proper containment |
| Table or collection cell on iOS 16+ | UIHostingConfiguration |
| Cell supporting iOS 15 or earlier | UIKit cell, or a carefully managed hosting-controller fallback |
| Shared interactive model on iOS 17+ | UIKit-owned @Observable model passed into SwiftUI |
| Existing Combine model | Keep ObservableObject until migration has a concrete benefit |
| Feature with several internal SwiftUI destinations | One hosting controller with a SwiftUI-owned NavigationStack |
| Highly optimized list with simple static rows | Measure first; UIKit labels may remain the better tool |
A successful migration does not maximize the percentage of SwiftUI. It reduces duplicated UI logic while leaving ownership clear. Start with a leaf screen or a visually rich cell, measure it, establish conventions for dependencies and actions, then expand the seam.
Key Takeaways
UIHostingControlleris the right bridge for screens, presentations, and contained regions because it has view-controller semantics.UIHostingConfigurationis the right bridge for table and collection cells on iOS 16 or later because it participates in content configuration and reuse.- UIKit should usually own the cross-framework route; SwiftUI should communicate through explicit actions.
- Use Observation for shared iOS 17 state, but keep state ownership and actor isolation explicit.
- Treat sizing, safe areas, reuse, task cancellation, and stable identity as part of the integration contract.
- Migrate feature by feature, and keep UIKit where replacing it would add risk without improving the product or codebase.
Conclusion
SwiftUI adoption inside UIKit works best when the boundary matches the job. Wrap a feature in UIHostingController when UIKit needs a controller. Configure a cell with UIHostingConfiguration when UIKit needs reusable content. Everything else—state, layout, navigation, and testing—becomes easier once that first choice is correct.
The real migration milestone is not the first rendered SwiftUI view. It is a seam your team understands: one owner for state, one owner for navigation, stable data identity, measurable layout behavior, and callbacks that make framework transitions visible. Build that seam once, document it, and the next SwiftUI feature can be an ordinary engineering decision rather than an application rewrite.