Skip to content
By • Published • Updated

How to Migrate UIKit to SwiftUI in 2026 Without a Rewrite

Short answer: migrate UIKit to SwiftUI incrementally. Host new SwiftUI screens in UIHostingController inside your existing navigation, wrap the UIKit components you keep with UIViewRepresentable, move state into @Observable models, and convert leaf screens first. Keep routing in UIKit until a whole flow is SwiftUI, then move that flow to NavigationStack.

Most UIKit codebases I see in 2026 have years of business logic inside view controllers, a coordinator or two, and a handful of SwiftUI screens somebody added in a hurry. Everyone agrees new work goes in SwiftUI. The open question is how to move the rest without freezing delivery for two quarters. This playbook covers the phased model I use, the interop code that holds the two stacks together, the mistakes that show up at the boundary, and a per-screen checklist for your definition of done.

Why Migration Efforts Fail

The failures are rarely about SwiftUI itself. They come from how the two stacks are stitched together. The ones I see most often:

  • Two sources of truth: a view controller property mirrored into an ObservableObject, with a bug every time one side forgets to update the other.
  • A NavigationStack inside a hosting controller already pushed on a UINavigationController: two navigation bars, two back stacks.
  • Screen-view analytics that lived in viewDidAppear and silently stopped firing when the screen became a SwiftUI view.
  • Hosted views that push or present on their own, so the coordinator loses track and deep links break.
  • No flag back to the UIKit screen, so the first regression is a hotfix release instead of a config change.

Each is cheap to prevent with a rule set before the first screen moves, and expensive to fix after twenty screens have copied the pattern.

Should You Migrate from UIKit to SwiftUI at All?

Not every app needs it. Migration pays off when you are still building features on the UI layer, when UIKit boilerplate is slowing reviews and onboarding, or when you want widgets, Live Activities, or new platform surfaces that are SwiftUI-only. It pays off less in these cases:

  • The app is in maintenance mode and the UI rarely changes.
  • A screen depends on fine-grained UIKit behaviour, such as complex compositional collection view layouts, custom transitions, or TextKit-based editing. Keep those in UIKit and wrap them.
  • A critical launch leaves no room for the team’s SwiftUI learning curve.
  • Your user base forces a minimum iOS version where the APIs you need do not exist yet.

A hybrid app that keeps some UIKit indefinitely is a perfectly good end state.

Minimum iOS version trade-offs

Deployment targetWhat you getWhat you work around
iOS 15.task with async/await, ObservableObject, List refresh and searchOnly NavigationView, so keep navigation in UIKit
iOS 16NavigationStack, UIHostingConfiguration for cells, hosting controller sizingOptionsStill ObservableObject and @Published for state
iOS 17+@Observable and @Bindable, finer-grained view updatesFewest workarounds; my recommended floor in 2026

Phase 1: Baseline and Readiness (2-3 Weeks)

Before you convert a single screen, record where the app stands so you can tell later whether the migration helped or hurt. Pull these from tools you already have:

  • Crash-free sessions per feature area, and launch time percentiles from the Xcode Organizer or MXAppLaunchMetric.
  • Time-to-interactive for the three or four highest-traffic screens, measured with os_signpost intervals so you can compare later.
  • Open UI defects per module, so you know which screens are already fragile.
  • A list of every UIViewController subclass with its parent, children, and what it presents. A short script over the source tree does this; it becomes the migration inventory.

Classify each screen in the inventory by how hard it will be to move:

  • Low: read-only detail screens and simple forms with no child controllers.
  • Medium: moderate state, validation, one or two embedded components.
  • High: custom collection view layouts, interactive transitions, text editing, or anything that owns child view controllers that have not been migrated.

Start with low and medium screens. The point of the first wave is to produce a pattern the rest of the team can copy, not to move the most code.

Phase 2: How Do UIKit and SwiftUI Work Together in One App?

The migration succeeds or fails at the boundary between old and new UI stacks. Apple gives you two directions of interop, and you will use both:

  • SwiftUI inside UIKit: UIHostingController for whole screens and child views, UIHostingConfiguration for table and collection view cells.
  • UIKit inside SwiftUI: UIViewRepresentable for views and UIViewControllerRepresentable for view controllers you are not ready to replace.

Push a SwiftUI screen from UIKit

This is the workhorse of an incremental migration. The UIKit navigation controller and coordinator stay in charge; only the screen content changes. Set the title on the host rather than relying on .navigationTitle inside the view to bridge across; it is one less thing to debug.

let model = ProfileModel(service: profileService)
let host = UIHostingController(rootView: ProfileView(model: model))
host.title = "Profile"
navigationController?.pushViewController(host, animated: true)

Embed SwiftUI as a child of a UIKit screen

When only part of a screen is migrating, add the hosting controller as a child view controller so lifecycle and trait changes propagate correctly. Without sizingOptions the hosted view reports no intrinsic size and Auto Layout gives it zero height.

let host = UIHostingController(rootView: SummaryCard(model: model))
host.sizingOptions = .intrinsicContentSize   // iOS 16+
addChild(host)
host.view.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(host.view)
NSLayoutConstraint.activate([
    host.view.topAnchor.constraint(equalTo: containerView.topAnchor),
    host.view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
    host.view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
    host.view.trailingAnchor.constraint(equalTo: containerView.trailingAnchor)
])
host.didMove(toParent: self)

Two hosting details worth knowing before they cost you an afternoon: a hosted view above a keyboard can pick up extra padding because the host applies the keyboard safe area, and on iOS 16.4 and later host.safeAreaRegions = [] turns that off; and SwiftUI environment values do not cross the host boundary, so inject dependencies into the rootView explicitly or with .environment() on it.

Render SwiftUI rows in an existing table or collection view

On iOS 16 and later you can migrate cells before you migrate the list that contains them. Self-sizing works, and the cell keeps its UIKit selection and swipe behaviour:

cell.contentConfiguration = UIHostingConfiguration {
    OrderRow(order: order)
}

Wrap a UIKit view you are keeping

For a legacy control that is not worth rewriting yet, such as a custom chart, wrap it with UIViewRepresentable. The coordinator turns delegate callbacks into closures. Keep updateUIView cheap and idempotent; SwiftUI calls it on every relevant state change.

struct LegacyChart: UIViewRepresentable {
    var points: [ChartPoint]
    var onSelect: (ChartPoint) -> Void

    func makeUIView(context: Context) -> ChartView {
        let view = ChartView()
        view.delegate = context.coordinator
        return view
    }

    func updateUIView(_ view: ChartView, context: Context) {
        context.coordinator.onSelect = onSelect
        view.setPoints(points)
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(onSelect: onSelect)
    }

    final class Coordinator: NSObject, ChartViewDelegate {
        var onSelect: (ChartPoint) -> Void
        init(onSelect: @escaping (ChartPoint) -> Void) { self.onSelect = onSelect }

        func chartView(_ view: ChartView, didSelect point: ChartPoint) {
            onSelect(point)
        }
    }
}

Bridge rules I enforce on every migration:

  • Every representable wrapper gets a ticket for its planned retirement, or a note saying it stays.
  • One observable model per feature boundary, owned by whoever creates the screen.
  • The same dependency container serves UIKit and SwiftUI modules; no second injection scheme.
  • Hosted SwiftUI views never push or present directly; they call back to the coordinator.

The same feature-boundary discipline is what lets one codebase serve several products. For a worked example, see how a white-label iOS app ships one Swift codebase as many branded apps.

Phase 3: Incremental Screen-by-Screen Migration

Move feature-by-feature, not file-by-file. A feature migration unit should include view, interactions, analytics events, error states, and accessibility checks. The safest unit is a leaf screen: one with no child view controllers, so it can be rebuilt in SwiftUI and swapped in behind a UIHostingController without touching anything around it.

Recommended order:

  1. Read-only detail views
  2. Settings and profile surfaces
  3. Simple forms with straightforward validation
  4. Reusable cells and components inside UIKit lists
  5. High-traffic flows after pattern stability is proven

Keep a short scorecard per sprint: screens migrated, defects introduced in them, and whether feature lead time moved. If defects grow faster than screens for two sprints, stop and fix the pattern.

How Should State Management Map from UIKit to SwiftUI?

Pick one state strategy and enforce it. In practice that means a feature model per screen that holds state and logic, with views treated as rendering plus user intent. The model does not know whether it is being shown by a view controller or a SwiftUI view.

UIKit concept to SwiftUI equivalent

UIKitSwiftUI
View controller holding state and logic@Observable model (iOS 17+) or ObservableObject (iOS 13-16)
Owning a model in viewDidLoad@State for an @Observable model, @StateObject for ObservableObject
Passing a shared service down the controller tree.environment(model) on the root view and @Environment(Model.self) where it is read
Target-action and delegatesClosures and model methods; @Bindable for two-way edits
KVO and NotificationCenter for UI refreshObservation tracking; .onReceive for remaining publishers
UITableView / UICollectionViewList or LazyVStack, or UIHostingConfiguration cells first
UINavigationController pushNavigationStack with navigationDestination(for:) (iOS 16+)

A model that works the same whether the screen is hosted from UIKit or lives in a pure SwiftUI flow:

import Observation
import SwiftUI

@MainActor
@Observable
final class ProfileModel {
    var name = ""
    var isSaving = false
    var errorMessage: String?

    private let service: ProfileService
    init(service: ProfileService) { self.service = service }

    func save() async {
        isSaving = true
        defer { isSaving = false }
        do {
            try await service.update(name: name)
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

struct ProfileView: View {
    @Bindable var model: ProfileModel

    var body: some View {
        Form {
            TextField("Name", text: $model.name)
            Button("Save") { Task { await model.save() } }
                .disabled(model.isSaving)
        }
    }
}

The same model can drive a UIKit screen that has not migrated yet. On iOS 17 you observe it with withObservationTracking(_:onChange:); on iOS 26 UIKit tracks @Observable reads in viewWillLayoutSubviews and updateProperties() automatically, and the UIObservationTrackingEnabled Info.plist key enables that back to iOS 18. Either way the view controller stops owning state, which is exactly the shape that makes replacing it trivial.

  • Keep domain and networking layers UI-agnostic so both stacks call the same code.
  • No business logic in views, no view code in models. The compiler will not stop you; review has to.
  • Load with .task rather than onAppear plus a detached Task; it cancels when the view disappears, so you do not update a model nobody is looking at.

How Do You Migrate Navigation to NavigationStack?

Navigation is often the hardest part because UIKit and SwiftUI have different mental models. My recommendation is two stages.

Stage one: UIKit keeps routing. Hosted SwiftUI screens expose closures, and the existing coordinator decides where to go. Do not put a NavigationStack inside a hosting controller that is already pushed onto a UINavigationController; you get two navigation bars and two back stacks.

struct OrderListView: View {
    let orders: [Order]
    var onSelect: (Order) -> Void

    var body: some View {
        List(orders) { order in
            Button(order.title) { onSelect(order) }
        }
    }
}

// In the UIKit coordinator
let list = OrderListView(orders: orders) { [weak self] order in
    self?.showDetail(for: order)
}
navigationController.pushViewController(UIHostingController(rootView: list), animated: true)

Stage two: a whole flow moves to SwiftUI. Once every screen in a tab or flow is SwiftUI, replace that flow’s navigation controller with a NavigationStack driven by a typed path. Deep links become an array of routes you assign to the path. Attach navigationDestination(for:) once, to the root content, not inside a lazy container; a destination declared inside a List row is registered late and the push can fail silently.

enum Route: Hashable {
    case order(Order.ID)
    case settings
}

struct OrdersFlow: View {
    @State private var path: [Route] = []

    var body: some View {
        NavigationStack(path: $path) {
            OrdersHome(open: { path.append($0) })
                .navigationDestination(for: Route.self) { route in
                    switch route {
                    case .order(let id): OrderDetailView(id: id)
                    case .settings: SettingsView()
                    }
                }
        }
    }
}

Rules for both stages:

  • Top-level routing stays in one place, even if that place is a UIKit coordinator for another year.
  • Deep-link parsing produces route values, never views or view controllers, so both stacks can consume them.
  • No ad-hoc push logic inside feature modules, in either stack.
  • Log route transitions from the router, so analytics do not depend on which stack rendered the screen.

How Do You Test a UIKit to SwiftUI Migration?

Moving logic into models is what makes the new screens testable without a UI harness. A model test runs in milliseconds and does not care which stack renders the result:

import Testing

@MainActor
@Test func saveClearsSavingFlag() async {
    let model = ProfileModel(service: MockProfileService())
    model.name = "Ada"
    await model.save()
    #expect(model.isSaving == false)
    #expect(model.errorMessage == nil)
}

For visual parity, preview the old and new screens side by side. Since Xcode 15 the #Preview macro accepts UIKit view controllers as well as SwiftUI views:

#Preview("UIKit checkout") {
    UINavigationController(rootViewController: CheckoutViewController(model: .preview))
}

#Preview("SwiftUI checkout") {
    CheckoutView(model: .preview)
}

Then set release gates per migrated screen:

  • Snapshot tests for high-traffic screens, recorded from the UIKit version before it is replaced.
  • Unit tests for the model, covering the error and empty states the old view controller handled.
  • UI automation for anything that touches money or identity.
  • A feature flag back to the UIKit screen, kept for at least one full release.
  • A phased App Store release with crash and launch-time thresholds checked each day.

Write the rollback rule down before release, with a number in it. For example: if crash-free sessions for the migrated module fall more than 0.2 percentage points below the Phase 1 baseline, flip the flag back. The exact threshold matters less than having one nobody has to negotiate at 11pm.

Accessibility and Design System Alignment

SwiftUI gets a lot of accessibility right by default, which makes the regressions it does introduce easy to miss. Check these on every migrated screen:

  • VoiceOver labels and reading order. Controls built from Image and Button need .accessibilityLabel; grouped rows usually want .accessibilityElement(children: .combine).
  • Dynamic Type at the largest sizes. Use semantic fonts and @ScaledMetric for spacing that was a fixed constant in UIKit.
  • Colour contrast through tokens shared by both stacks, so a UIColor asset and its Color counterpart cannot drift.
  • XCUIApplication.performAccessibilityAudit() (Xcode 15+) in the screen’s UI test, which catches missing labels and clipped text.

Share one component library across the coexistence period. A button that looks different depending on which stack rendered it is the kind of inconsistency users notice and nobody files a bug for.

How Long Does a UIKit to SwiftUI Migration Take?

These are the planning ranges I start from; codebase shape, team familiarity with SwiftUI, and how much feature work continues in parallel move them in either direction.

  • Light modernization: 6-10 weeks to move a handful of key screens and establish the pattern.
  • Mid-size migration: 3-5 months for core flows plus a shared component system.
  • Large enterprise migration: 6-12 months, phased by domain, with UIKit remaining in places.

The number that matters at the end is whether the screens you touched now ship faster with fewer defects than they did in Phase 1, not the percentage of SwiftUI.

What to Measure After Migration Starts

  • Feature lead time for migrated modules versus the Phase 1 baseline
  • Crash-free sessions and launch time for migrated modules, from the same sources as Phase 1
  • UI defects found in QA per migrated screen
  • How often the rollback flag was actually used

If those are not improving after the first wave, the pattern is wrong. Fix it before widening scope.

UIKit to SwiftUI Migration Checklist (Per Screen)

Put this into the definition of done for every migrated screen:

  1. The screen is a leaf, or its child view controllers are already migrated or wrapped.
  2. Business logic has moved out of the view controller into a model with unit tests.
  3. The SwiftUI view is hosted with UIHostingController, and navigation still goes through the coordinator.
  4. Analytics events fire with the same names and parameters as the UIKit version.
  5. Loading, empty, and error states match or improve on the old screen.
  6. Dynamic Type, VoiceOver labels and order, and dark mode are checked.
  7. Old and new screens are compared in previews or snapshot tests.
  8. A feature flag can route back to the UIKit screen for one release.
  9. Crash-free rate and screen performance are watched against the Phase 1 baseline after release.
  10. The UIKit screen and its flag are deleted once the new screen is stable.

90-Day Migration Execution Checklist

If you want the phases above as a calendar, this is the sequence I use for a team that has to keep shipping while it migrates:

  • Days 1-15: Baseline metrics, screen inventory, written bridge rules, and a named owner per feature area.
  • Days 16-30: Hosting and routing helpers, the model template, the rollback flag mechanism, and one reference screen that uses all of them.
  • Days 31-50: Low-risk screens, analytics parity checks, and the first adjustments to the review checklist.
  • Days 51-70: Medium-complexity flows with snapshot tests and a phased release.
  • Days 71-90: One high-value module, a regression review against the baseline, and updated architecture docs.

The pacing is deliberately slow at the start. Plans that front-load screen count produce QA debt that surfaces two or three releases later. A team already carrying incident load should start with fewer modules and stricter gates.

Where to Start

Pick one leaf screen, move its logic into a model, host the SwiftUI version behind a flag, and ship it. Everything else in this playbook is about making that step repeatable.

If you need hands-on help, I support both complete iOS app development and focused SwiftUI development and UIKit migration. If you want an outside read on the codebase before committing, start with a mobile architecture audit.

Sources

Frequently Asked Questions

Should we rewrite the entire UIKit app in SwiftUI at once?

No. Most teams should migrate incrementally. Full rewrites increase schedule risk, regression risk, and feature freeze pressure.

Can UIKit and SwiftUI live in the same production app?

Yes. Hybrid architecture using UIHostingController and UIViewControllerRepresentable is a common and safe migration model.

What is the biggest migration risk?

The biggest risk is inconsistent architecture where state ownership and navigation patterns differ by feature. Define migration standards before scaling implementation.

What minimum iOS version do I need to migrate to SwiftUI?

SwiftUI runs on iOS 13 and later, but the migration gets much easier as the floor rises. iOS 16 adds NavigationStack and UIHostingConfiguration for cells, and iOS 17 adds the @Observable macro. If your analytics allow it, I recommend iOS 17 as the minimum for a migration that starts in 2026.

Can I preview a UIKit view controller in Xcode?

Yes. Since Xcode 15 the #Preview macro accepts a UIViewController or UIView as well as a SwiftUI view, so you can preview existing UIKit screens side by side with the SwiftUI versions that replace them.

When should you not migrate from UIKit to SwiftUI?

Skip or delay migration when the app is in maintenance mode, when a screen depends on fine-grained UIKit behaviour such as complex collection view layouts or TextKit editing, or when a critical launch leaves no room for the learning curve. Hybrid apps that keep some UIKit indefinitely are normal.

Puneetpal Arneja, Senior iOS Developer & Mobile Architect

Written by

Staff Mobile Engineer at Albertsons · 13+ years building mobile apps

I've shipped 18+ mobile applications across banking, healthcare, and consumer products — including work for Citibank, Apple, PNC, and Albertsons. Everything here comes out of production work, not theory.

Planning a UIKit to SwiftUI Migration?

I help teams define migration boundaries, reduce regression risk, and deliver SwiftUI adoption while keeping product releases on schedule.

Related Services & Further Reading