DEV Community

Puneetpal Arneja
Puneetpal Arneja

Posted on Originally published at puneetpal.dev

UIKit to SwiftUI Migration: 2026 Playbook with Code

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 target What you get What you work around
iOS 15 .task with async/await, ObservableObject, List refresh and search Only NavigationView, so keep navigation in UIKit
iOS 16 NavigationStack, UIHostingConfiguration for cells, hosting controller sizingOptions Still ObservableObject and @Published for state
iOS 17+ @Observable and @Bindable, finer-grained view updates Fewest 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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

  • Read-only detail views
  • Settings and profile surfaces
  • Simple forms with straightforward validation
  • Reusable cells and components inside UIKit lists
  • 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

UIKit SwiftUI
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 delegates Closures and model methods; @Bindable for two-way edits
KVO and NotificationCenter for UI refresh Observation tracking; .onReceive for remaining publishers
UITableView / UICollectionView List or LazyVStack, or UIHostingConfiguration cells first
UINavigationController push NavigationStack 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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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()
                    }
                }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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:

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


Originally published at puneetpal.dev.

Top comments (0)