# Hosting and Incremental Migration

A UIKit app does not have to switch to SwiftUI in one release. These six
patterns move it over piece by piece while both frameworks share the same
navigation stack, the same models and the same traits.

## Contents

1. [Replace whole screens](#1-screen-by-screen-migration)
2. [Embed a SwiftUI region](#2-uihostingcontroller-as-child)
3. [Share one navigation stack](#3-navigation-bridging)
4. [Share models](#4-data-sharing-between-uikit-and-swiftui)
5. [SwiftUI cells](#5-uihostingconfiguration-ios-16)
6. [Pass environment and traits](#6-environment-bridging)

## 1. Screen-by-Screen Migration

The lowest-risk route is to replace whole view controllers, one at a time, with
a `UIHostingController` that wraps the new SwiftUI screen.

Order of work:

1. Choose a leaf screen, one that embeds no child view controllers.
2. Rebuild it in SwiftUI.
3. Replace the old controller wherever it was instantiated.
4. Connect the surrounding UIKit navigation to the hosting controller.

```swift
// Before: navigationController?.pushViewController(InvoiceDetailViewController(invoice: invoice), animated: true)
let detail = InvoiceDetailView(invoice: invoice, onVoid: { [weak self] in
    self?.ledger.void(invoice)
    self?.navigationController?.popViewController(animated: true)
})
let host = UIHostingController(rootView: detail)
navigationController?.pushViewController(host, animated: true)
```

The SwiftUI view takes a callback such as `onVoid`; the UIKit side supplies the
closure that changes data and pops the screen.

Navigation bar behaviour:

- `.navigationTitle()` and `.toolbar()` inside the hosted view show up in the
  UIKit navigation bar. Whether the bar is visible is decided by the parent
  `UINavigationController`.
- If `.navigationBarTitleDisplayMode()` does not take effect, set
  `host.navigationItem.largeTitleDisplayMode` from UIKit instead.
- `UIHostingController` respects `additionalSafeAreaInsets`. If content slides
  under the tab bar, check how the safe area reaches the hosted view.

## 2. UIHostingController as Child

Use a child hosting controller to migrate a region of a screen, such as a
header, a card or a section, before rewriting the rest.

```swift
final class AccountViewController: UIViewController {
    private var balanceHost: UIHostingController<BalanceCard>?
    private let cardContainer = UIView()

    override func viewDidLoad() {
        super.viewDidLoad()
        let host = UIHostingController(rootView: BalanceCard(amount: 0))
        if #available(iOS 16.0, *) {
            host.sizingOptions = [.intrinsicContentSize]
        }
        host.view.backgroundColor = .clear

        addChild(host)
        host.view.translatesAutoresizingMaskIntoConstraints = false
        cardContainer.addSubview(host.view)
        NSLayoutConstraint.activate([
            host.view.topAnchor.constraint(equalTo: cardContainer.topAnchor),
            host.view.bottomAnchor.constraint(equalTo: cardContainer.bottomAnchor),
            host.view.leadingAnchor.constraint(equalTo: cardContainer.leadingAnchor),
            host.view.trailingAnchor.constraint(equalTo: cardContainer.trailingAnchor)
        ])
        host.didMove(toParent: self)
        balanceHost = host
    }

    func show(amount: Decimal) {
        balanceHost?.rootView = BalanceCard(amount: amount)
    }
}
```

With an `@Observable` model passed into the card instead, mutate the model and
leave `rootView` alone; SwiftUI picks up the change.

Pitfalls:

- By default the hosting view paints the opaque system background. Set
  `view.backgroundColor = .clear` when it sits over existing UIKit content.
- Without `.intrinsicContentSize` the hosted view can report a zero size and
  the container collapses.
- Keep a stored reference to the hosting controller. Removing the child would
  otherwise free it and the view vanishes.

## 3. Navigation Bridging

UIKit screens and SwiftUI screens can live on the same `UINavigationController`
stack.

**UIKit pushes SwiftUI.** Wrap the view, give the host a `title`, push it:

```swift
let host = UIHostingController(rootView: ReceiptsView())
host.title = "Receipts"
navigationController?.pushViewController(host, animated: true)
```

**SwiftUI presents UIKit.** Present a representable from `.sheet` that returns
a `UINavigationController(rootViewController:)` around the legacy controller:

```swift
struct LegacyExportFlow: UIViewControllerRepresentable {
    func makeUIViewController(context: Context) -> UINavigationController {
        UINavigationController(rootViewController: ExportOptionsViewController())
    }
    func updateUIViewController(_ nav: UINavigationController, context: Context) {}
}

// .sheet(isPresented: $exporting) { LegacyExportFlow() }
```

**Deep integration.** Give SwiftUI views a small bridge to the UIKit stack
through the environment:

```swift
struct UIKitNavigator {
    weak var navigationController: UINavigationController?

    @MainActor
    func push(_ controller: UIViewController, animated: Bool = true) {
        navigationController?.pushViewController(controller, animated: animated)
    }

    @MainActor
    func push<Content: View>(_ view: Content, title: String, animated: Bool = true) {
        let host = UIHostingController(rootView: view)
        host.title = title
        navigationController?.pushViewController(host, animated: animated)
    }
}

private struct UIKitNavigatorKey: EnvironmentKey {
    static var defaultValue: UIKitNavigator { UIKitNavigator() }
}

extension EnvironmentValues {
    var uikitNavigator: UIKitNavigator {
        get { self[UIKitNavigatorKey.self] }
        set { self[UIKitNavigatorKey.self] = newValue }
    }
}
```

Set it on the root view before creating the host:
`ReceiptsView().environment(\.uikitNavigator, UIKitNavigator(navigationController: navigationController))`.

Notes:

- A pushed hosting controller gets the standard back button automatically. Do
  not add your own.
- Do not put a `NavigationStack` inside a hosting controller that is already on
  a `UINavigationController`; you get two navigation bars. Remove the
  `NavigationStack`.
- On iOS 16 and later, SwiftUI `.toolbar` items reliably appear in the UIKit
  navigation bar.

## 4. Data Sharing Between UIKit and SwiftUI

**Preferred (iOS 17+): one `@Observable` model for both sides.** SwiftUI tracks
it automatically; UIKit reads its properties directly.

```swift
@Observable
@MainActor
final class CartModel {
    var itemCount = 0
}
```

`@Observable` does not push changes into UIKit by itself. A UIKit controller
has to observe explicitly, for example with `withObservationTracking` inside a
long-lived task:

```swift
final class CartBadgeViewController: UIViewController {
    private let cart: CartModel
    private var observation: Task<Void, Never>?

    init(cart: CartModel) {
        self.cart = cart
        super.init(nibName: nil, bundle: .main)
    }
    required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }

    override func viewDidLoad() {
        super.viewDidLoad()
        observation = Task { @MainActor [weak self, cart] in
            while !Task.isCancelled {
                await withCheckedContinuation { continuation in
                    withObservationTracking {
                        self?.title = "\(cart.itemCount) items"
                    } onChange: {
                        continuation.resume()
                    }
                }
            }
        }
    }

    deinit { observation?.cancel() }
}
```

Each pass applies the current value, registers tracking, and suspends until
the next change. A variant that loops and yields with `Task.sleep(for: .zero)`
also works, but it wakes constantly; waiting on the `onChange` signal does not.
The task holds `self` weakly, and `deinit` cancels it; a cancelled task leaves the loop at the next change.

**Legacy (iOS 15 and 16, or an `ObservableObject`).** Subscribe to
`objectWillChange` with Combine and keep the cancellable:

```swift
cancellable = store.objectWillChange
    .receive(on: RunLoop.main)
    .sink { [weak self] _ in
        self?.refreshBadge()
    }
```

Rules:

- Change UI-driving properties of an `@Observable` model on the `@MainActor`.
- Capture `[weak self]` in sinks and task closures. Keep cancellables and tasks
  in stored properties and cancel them in `deinit`.

## 5. UIHostingConfiguration (iOS 16+)

This is the preferred way to put SwiftUI content into UIKit collection and
table view cells.

```swift
cell.contentConfiguration = UIHostingConfiguration {
    HStack(spacing: 12) {
        AsyncImage(url: product.thumbnailURL) { image in
            image.resizable().scaledToFill()
        } placeholder: {
            ProgressView()
        }
        .frame(width: 56, height: 56)
        .clipShape(.rect(cornerRadius: 8))
        Text(product.name)
    }
}
.margins(.all, 12)
```

Table views work the same way inside `tableView(_:cellForRowAt:)`:

```swift
let cell = tableView.dequeueReusableCell(withIdentifier: "order", for: indexPath)
cell.contentConfiguration = UIHostingConfiguration {
    OrderRowView(order: orders[indexPath.row])
}
return cell
```

Configuration modifiers include `.background { }`, `.margins(.horizontal, 16)`
and `.minSize(height: 60)`.

Notes:

- Self-sizing needs a `UICollectionViewCompositionalLayout` with estimated
  dimensions (for tables, `rowHeight` set to `UITableView.automaticDimension`), plus
  SwiftUI content that has a definite height.
- Behind every configuration sits a small hosting controller. For huge
  lists (10,000 items and up) measure with Instruments.
- The content is rebuilt when a cell is reused. `@State` inside it does not
  survive reuse; keep such state in the data model.
- Swipe actions belong to UIKit (`leadingSwipeActionsConfigurationForRowAt`),
  not to the SwiftUI content.
- Environment values from the surrounding UIKit context are not passed in
  automatically. Inject what you need inside the configuration closure.

## 6. Environment Bridging

Environment has to be attached before the hosting controller exists. Apply
`.environment(model)` (iOS 17, for an `@Observable` model),
`.environment(\.locale, ...)` and similar modifiers to the root view, then
create the host. Adding environment afterwards means
assigning a new `rootView`.

```swift
let root = SettingsView()
    .environment(session)
    .environment(\.locale, Locale(identifier: "fr_FR"))
let host = UIHostingController(rootView: root)
```

Traits that bridge on their own:

| UIKit trait | SwiftUI environment |
|-------------|---------------------|
| `userInterfaceStyle` | `\.colorScheme` |
| `horizontalSizeClass` | `\.horizontalSizeClass` |
| `verticalSizeClass` | `\.verticalSizeClass` |
| `preferredContentSizeCategory` | `\.dynamicTypeSize` |
| `layoutDirection` | `\.layoutDirection` |
| `legibilityWeight` | `\.legibilityWeight` |

These stay current as the device rotates, a split view resizes, or an
accessibility setting changes. `host.overrideUserInterfaceStyle` forces light
or dark and flows through to `\.colorScheme`.

**Custom values.** Declare an `EnvironmentKey` with a `defaultValue`, extend
`EnvironmentValues`, set it from UIKit with `.environment(\.yourKey, value)`
and read it in SwiftUI with `@Environment(\.yourKey)`. The navigator in pattern
3 is an example.

**Changing environment after creation (iOS 17+).** Wrap the content in a
container view that reads an `@Observable` state object and applies the environment. Mutating
that object then updates the environment without touching `rootView`.

```swift
@Observable
@MainActor
final class HostedEnvironment {
    var locale = Locale.current
}

struct EnvironmentBridge<Content: View>: View {
    let state: HostedEnvironment
    @ViewBuilder var content: Content

    var body: some View {
        content.environment(\.locale, state.locale)
    }
}
```

Pitfalls:

- `@Environment(\.dismiss)` inside a hosted view works when the host was shown
  with `present(_:animated:)`. It does nothing when the host was pushed onto a
  `UINavigationController`; call `popViewController(animated:)` there instead.
- A required environment object that is missing crashes at runtime. Set every
  one before creating the hosting controller.
