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 UITableViewCell or UICollectionViewCell;
  • 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:

  1. Recreating a screen host on every update. Retain the controller or shared model. Replace rootView intentionally when using immutable snapshots; do not rebuild the whole controller during every UIKit layout pass.
  2. Doing work in body. SwiftUI may evaluate body frequently. Move image decoding, formatting caches, database access, and expensive transformations outside the view.
  3. Using unstable identity in cells. Random IDs make diffable snapshots and SwiftUI both treat old content as new. Use domain identifiers.
  4. Starting duplicate tasks. A SwiftUI .task can restart when identity or hierarchy changes. Make loads cancellable and idempotent, or let a longer-lived model own them.
  5. 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.
  6. Enabling dynamic ideal-size tracking everywhere. sizingOptions can 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.

SituationRecommended approach
New independent screen in a UIKit flowPush or present a UIHostingController
SwiftUI panel inside an existing UIKit screenChild UIHostingController with proper containment
Table or collection cell on iOS 16+UIHostingConfiguration
Cell supporting iOS 15 or earlierUIKit cell, or a carefully managed hosting-controller fallback
Shared interactive model on iOS 17+UIKit-owned @Observable model passed into SwiftUI
Existing Combine modelKeep ObservableObject until migration has a concrete benefit
Feature with several internal SwiftUI destinationsOne hosting controller with a SwiftUI-owned NavigationStack
Highly optimized list with simple static rowsMeasure 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

  • UIHostingController is the right bridge for screens, presentations, and contained regions because it has view-controller semantics.
  • UIHostingConfiguration is 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.