Start a project

Article · iOS · SwiftUI · Migration · 14 min read

How to migrate a UIKit app to SwiftUI

You do not have to rewrite a UIKit app in SwiftUI in one go. The reliable way is to pick a seam — a single screen, a table cell, a settings pane — host the new SwiftUI in a UIHostingController, or wrap the UIKit you must keep with UIViewRepresentable, and share one source of state between them. Mixed UIKit and SwiftUI in the same target is a supported, normal state. It is often a better outcome than a stalled rewrite.

On this page 10 sections
  1. Start with a seam, not a rewrite
  2. Hosting SwiftUI in UIKit, and UIKit in SwiftUI
  3. Sharing state across the two frameworks
  4. Navigation: UINavigationController and NavigationStack together
  5. Storyboards, XIBs and programmatic UI
  6. Testing the migrated pieces
  7. A sensible order of work
  8. When to leave a screen in UIKit
  9. Key takeaways
  10. Frequently asked questions

I have done this on shipping apps, not greenfield demos. This is not a first SwiftUI tutorial, and it is not an argument for native versus Flutter or React Native; if you are still choosing a stack, start with native iOS or a cross-platform toolkit. What follows is a method for moving a UIKit app, screen by screen, without taking it out of the App Store.

Start with a seam, not a rewrite

A rewrite looks tidy in a plan and expensive in practice. You freeze feature work, you rediscover every edge case the old screens already handled, and for months you have two apps that both have to build. Incremental migration keeps one target, one release train and one set of users.

A good first seam has four qualities:

Write down the seam before you touch code. The commit that introduces SwiftUI should be small enough that a reviewer can see the old entry point, the new host, and the route back. A Conventional Commit linter helps keep a long-running migration branch readable: one screen or one wrapper per commit beats “WIP SwiftUI” a hundred times.

Resist the urge to “just convert the app delegate and the tab bar while we are here”. Those are the last things you should move, because everything else hangs off them.

Hosting SwiftUI in UIKit, and UIKit in SwiftUI

The two bridging types you will use constantly are UIHostingController and UIViewRepresentable (or UIViewControllerRepresentable when the UIKit side is a view controller). Apple documents both as the supported way to mix the frameworks in one app.

Hosting SwiftUI from UIKit is usually the first move, because you are adding a new screen rather than dismantling an old one:

import SwiftUI
import UIKit

func showAccount(from presenter: UIViewController, model: AccountModel) {
    let root = AccountView(model: model)
    let host = UIHostingController(rootView: root)
    host.title = "Account"
    presenter.navigationController?.pushViewController(host, animated: true)
}

The hosting controller is a normal UIViewController. You can push it, present it, put it in a tab, or embed it with Auto Layout as a child. Set title, navigationItem and tab bar items on the host, not inside the SwiftUI view, until that part of the tree is also in SwiftUI.

Going the other way matters when a screen is mostly SwiftUI but one piece — a map, a PKCanvasView, a battle-tested chart, a UICollectionView with a compositional layout you do not want to rebuild — should stay in UIKit:

import SwiftUI
import MapKit

struct LegacyMapView: UIViewRepresentable {
    var coordinate: CLLocationCoordinate2D

    func makeUIView(context: Context) -> MKMapView {
        let map = MKMapView()
        map.isRotateEnabled = false
        return map
    }

    func updateUIView(_ map: MKMapView, context: Context) {
        let region = MKCoordinateRegion(
            center: coordinate,
            latitudinalMeters: 800,
            longitudinalMeters: 800
        )
        map.setRegion(region, animated: false)
    }
}

Two habits save pain in representables:

On iOS 16 and later, UIHostingConfiguration lets you put SwiftUI inside a UICollectionView or UITableView cell without a full-screen host. That is a good seam for a list that must stay in UIKit while its cells move.

Sharing state across the two frameworks

The migration fails when each side keeps its own copy of the same facts. Pick one owner for feature state and app state, then let both frameworks read it. The structure I use in SwiftUI state and architecture still applies: view state stays in the view, feature state lives in an observable model, app state is created once at the top.

For a mixed app, the model cannot be “a SwiftUI-only object that UIKit has never heard of”. A practical pattern on iOS 17 and later is an @Observable class that UIKit can hold and that SwiftUI can watch:

import Foundation
import Observation

@Observable
final class AccountModel {
    var name: String = ""
    var isLoading = false
    var errorMessage: String?

    func reload() async {
        isLoading = true
        defer { isLoading = false }
        do {
            name = try await AccountClient().fetchName()
            errorMessage = nil
        } catch {
            errorMessage = "Could not load your account."
        }
    }
}

Create it in the UIKit side you already have — a tab bar controller, a coordinator, the scene’s starting view controller — and pass the same instance into SwiftUI:

let model = AccountModel()
let host = UIHostingController(rootView: AccountView(model: model))
import SwiftUI

struct AccountView: View {
    var model: AccountModel

    var body: some View {
        List {
            Text(model.name)
            if model.isLoading { ProgressView() }
        }
        .task { await model.reload() }
    }
}

UIKit updates the same model with ordinary property writes. SwiftUI will refresh because of Observation. If a UIKit screen must react to those writes, observe the model from UIKit with withObservationTracking, or give the model an explicit callback or Notification for the few events the UIKit side cares about. Do not secretly keep a second name on the view controller.

If you still support a deployment target before iOS 17, ObservableObject and @Published work as the shared model, with UIHostingController receiving the object through the SwiftUI tree. The idea is the same: one instance, two interfaces.

Shared API models deserve the same discipline. If a TypeScript website and the iOS app speak to the same JSON API, treat the payload as a contract, not as two invented types that drift. When I am checking a web client against a fixture the app already decodes, I use the JSON to TypeScript tool to see the shape clearly, then I go back and make the Swift Codable types match the same fields — including the awkward ones.

Leave the existing UINavigationController in charge until a whole flow is in SwiftUI. Pushing a UIHostingController onto a UIKit stack is boring, and boring is what you want: the back button, the swipe-to-pop, the tab’s navigation bar and any existing UIAdaptivePresentationController all keep working.

Move a flow to NavigationStack only when that flow is a self-contained SwiftUI tree — a sequence of screens that always start together and do not need to push a UIKit controller in the middle. A stack that sometimes pushes UIKit and sometimes pushes SwiftUI destinations is how you get two sources of truth for “what is on screen”.

A workable split:

If a SwiftUI flow must present a UIKit screen — a QLPreviewController, a document picker, a payment sheet — present it from the hosting controller, or wrap it in a UIViewControllerRepresentable shown with .sheet. Do not try to push that view controller onto a SwiftUI-managed path.

Typed routes, state restoration and deep links into a SwiftUI stack are a subject in their own right; I cover them separately in the NavigationStack guide in this series. For a migration, the rule is simpler: one owner for the visible stack at any moment.

Storyboards, XIBs and programmatic UI

You do not have to delete a storyboard the week you add SwiftUI. A storyboard can instantiate a UIHostingController subclass, or a thin UIViewController that creates the host in viewDidLoad. That is a valid interim step if the rest of the app still loads its screens from a storyboard.

A few practical points:

When you delete a storyboard scene, keep the old file in the commit history. The Diff Checker is useful for pasted snippets when you are reconciling two versions of the same screen, especially copy, accessibility labels and colour tokens.

Testing the migrated pieces

A migration is a chance to put tests around behaviour that used to live only in a view controller. The model you share between UIKit and SwiftUI should be testable without a window, the same way I describe in testing iOS apps with Swift Testing.

Test the model first: loading, failure, retry, the mapping of a response into fields the view displays. Those tests do not care whether a UILabel or a Text is showing the value, which is exactly why incremental migration can be safe.

Then test the seam:

Do not delete an existing UI test just because the destination is now SwiftUI. If the test tapped “Account” and expected a name to appear, keep that assertion. The user still takes the same path.

A sensible order of work

The order below is the one I come back to. It is deliberately not “convert the architecture, then the design system, then every screen”.

  1. Turn on SwiftUI in the existing target. Add a trivial hosted screen behind a hidden gesture or a debug flag if you want to prove the project builds, then delete it. You want the first real screen to be the first real commit.
  2. Extract or write a model for the first seam, with tests, before the SwiftUI view. If the view controller already has a tangle of networking and formatting, pull that out so the new view is not a second tangle.
  3. Ship the first hosted screen to production and wait a release. Watch crash reports and the obvious layout problems (safe area, keyboard, large Dynamic Type, landscape if you support it).
  4. Adopt a wrapping rule. New features are SwiftUI unless they must sit inside a UIKit container you are not ready to touch. Bug fixes on UIKit screens stay in UIKit unless the change is larger than the wrap.
  5. Move whole flows, not isolated mid-stack screens, once you have a few hosted leaves. A complete onboarding, a complete settings section, a complete detail-and-edit flow.
  6. Move lists and the root shell last. UICollectionView performance, existing prefetching, and the tab or split view are the parts most likely to regress, and they are the parts users see every day.
  7. Only then consider deleting UIKit from a flow that is entirely SwiftUI, including its tests and any storyboard references.

Time-box each step to a release you would ship anyway. A migration that is also a six-month feature freeze is a rewrite in disguise.

When to leave a screen in UIKit

Some screens should stay in UIKit for a long time, perhaps for the life of the app. That is not a failure of the migration.

Leave a screen in UIKit when:

Be honest in the project notes about what is staying and why. The next person should not assume that every UIKit file is technical debt.

Key takeaways

If you are planning a migration like this and want a second pair of eyes on the first seam, tell me about it.

Frequently asked questions

Can you mix UIKit and SwiftUI in the same app?

Yes. Apple supports it, and it is the usual state of a real app that did not start yesterday. A UIKit app can push or present a UIHostingController, embed SwiftUI in a cell with UIHostingConfiguration, and wrap remaining UIKit views for use inside SwiftUI. You can ship in that mixed form indefinitely.

How do you wrap a UIKit view in SwiftUI?

Conform a small struct to UIViewRepresentable (or UIViewControllerRepresentable). Create the view in makeUIView, push new inputs in updateUIView, and use a coordinator for delegate callbacks. Keep app logic out of the representable; it is an adapter around a view you already have.

Should you rewrite a UIKit app in SwiftUI all at once?

No. A rewrite freezes features and forces you to re-solve problems the old screens already handled. Start with one reachable seam, share state with the rest of the app, ship it, and only then take the next flow. Many apps should never convert every screen.

How do you share data between UIKit and SwiftUI?

Give both sides the same model instance. On iOS 17 and later an @Observable class works well: SwiftUI watches it, UIKit holds it and writes to it. On earlier deployment targets, ObservableObject fills the same role. Do not duplicate the same fields on a view controller and in a SwiftUI @State property.

How do you migrate UIKit navigation to NavigationStack?

Do it per flow, not per screen. Keep UINavigationController as the outer navigator and push a hosting controller for SwiftUI leaves. When a complete flow is in SwiftUI and it never needs to push UIKit in the middle, give that flow its own NavigationStack and a typed path. Avoid two bars and two stacks competing for the same visible screens.

Written by Lee Johnson. Published 10 November 2026.

All articles · Swift A–Z · Apps

Questions, or an app in mind?

Let's bring your ideas to life. Whether you're envisioning a game-changing Apple Watch app or a transformative iOS experience, I'm here to make it happen.

Start a project