On this page 10 sections
- Start with a seam, not a rewrite
- Hosting SwiftUI in UIKit, and UIKit in SwiftUI
- Sharing state across the two frameworks
- Navigation: UINavigationController and NavigationStack together
- Storyboards, XIBs and programmatic UI
- Testing the migrated pieces
- A sensible order of work
- When to leave a screen in UIKit
- Key takeaways
- 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:
- It is reachable from the existing navigation. You can push or present it from a
UIViewControlleryou already own. - Its boundary is clear. A settings screen, an onboarding step, a detail page or a new feature is easier than “the home tab, but only the middle”.
- It does not drag half the app with it. If the screen is a thin shell over a large, working view controller hierarchy, you will spend the week wrapping rather than shipping.
- You can test it. Prefer a screen whose behaviour you can describe in a model, so the first SwiftUI you add is not a one-off layout experiment.
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:
- Do work in
updateUIView(orupdateUIViewController) that depends on inputs, not inmakeUIView.makeruns once;updateruns whenever SwiftUI refreshes the view. - Use the coordinator for delegates and target-action, and keep it thin. The representable is an adapter, not a place to hide a second app architecture.
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.
Navigation: UINavigationController and NavigationStack together
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:
- UIKit owns the outer shell. Tab bar, split view, the root navigation controllers, deep links that land on a UIKit tab.
- SwiftUI owns a complete pushed flow. Once you have pushed the hosting controller, navigation inside it can be a
NavigationStackwith a typed path. - Do not nest a
NavigationStackinside aUINavigationControllerand also rely on the UIKit bar for the same stack. You will get double bars, missing back buttons, or a path that is out of step with what the user sees. Either hide the UIKit bar for that host (host.navigationController?.isNavigationBarHiddenis not enough on its own; prefer wrapping so only one bar exists) or keep the SwiftUI view as content without its own stack.
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:
- Give the host a programmatic initializer that takes the model it needs. Storyboard instantiation cannot pass that model through
init(coder:), so load the model in the parent and inject it, or resolve it from a small composition root, rather than reaching into a singleton fromviewDidLoad. - XIBs for a single custom view can wait. Wrap the view in
UIViewRepresentableif SwiftUI needs it, or rebuild it when you rebuild the screen. Do not spend a week converting a XIB into SwiftUI as a side project. - Auto Layout constraints in a parent that embeds a host should treat the hosting view like any other child: pin the
host.viewto the edges, sethost.view.backgroundColorif you see flashes of black, and call the usual child view controller containment methods (addChild,didMove(toParent:)). - Appearance proxies and
UINavigationBarappearance still apply to hosting controllers on a UIKit stack. Once a flow is fully in SwiftUI, move that styling intotoolbarand.toolbarBackgroundso you are not maintaining two themes.
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:
- The UIKit entry point still presents the right host with the right model. A test that a coordinator’s
showAccount()setsnavigationController.topViewControllerto aUIHostingControlleris enough; you do not need to render SwiftUI in that test. - The representable’s
updatemethod can be exercised by constructing it and callingupdateUIViewwith a new value, if the work is non-trivial (region changes, reload flags). Keep that logic in a function you can call without the SwiftUI runtime if it starts to grow. - Snapshot or UI tests belong on the finished screen, not on every wrapper. One or two journeys through the new SwiftUI screen, launched from the real UIKit parent, will catch double navigation bars and missing injection more quickly than a unit test will.
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”.
- 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.
- 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.
- 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).
- 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.
- 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.
- Move lists and the root shell last.
UICollectionViewperformance, existing prefetching, and the tab or split view are the parts most likely to regress, and they are the parts users see every day. - 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:
- It is stable and rarely touched. A working licence viewer or a dull settings child screen is not earning its rewrite.
- It depends on a UIKit API that is still the better tool.
UICollectionViewcompositional layouts with complex supplementary views,UITextViewwith text kits,PDFKit, some MapKit and PencilKit configurations, and a number of third-party views are still less painful as representables than as full rewrites. - Performance is already solved there. If you have profiled a list and it scrolls as it should, do not take that on faith in a
Listor aScrollViewrewrite. Revisit it when you have a measured problem, perhaps with the same Instruments approach I use when finding iOS performance problems. - A platform feature arrives through a view controller (a new system picker, a share sheet extension point, a document browser) and wrapping it is enough.
- You cannot yet test the replacement. If the screen’s behaviour is undefined, writing SwiftUI will not define it.
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
- Mix the frameworks in one app. Migrate a seam at a time, and ship that seam, rather than starting a second app in a second target.
- Use
UIHostingControllerto put SwiftUI on a UIKit stack, andUIViewRepresentableorUIViewControllerRepresentablefor the UIKit you must keep.UIHostingConfigurationis the right cell-level hook. - Share one observable model between both sides. Do not let each framework keep its own copy of the same account, list or flag.
- Leave
UINavigationControllerin charge of the outer shell until a whole flow is in SwiftUI. Do not run two navigation stacks for the same screens. - Test the model and the seam. Keep existing UI tests; they still describe the user’s path.
- Some UIKit should stay. Stable, rare, or genuinely better in UIKit is a reason, not an embarrassment.
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.