Skip to content
All essays

Mobile architecture

UIKit to SwiftUI migration strategy for a large app

Most SwiftUI migrations measure progress in screens converted. The ones that hold measure it in boundaries: a stable entry contract, one owner for state, one authority for navigation, a rollback that is a flag and not a revert. A strategy for a large UIKit app that keeps shipping.

12 min read

The plan usually arrives as a percentage. Twelve percent of screens are SwiftUI this quarter, the target is half by next year, and there is a dashboard that counts files. Six months later the app has SwiftUI views that read UIKit singletons, push view controllers directly, and cannot be turned off without a revert. The count went up. The app did not get easier to change.

I have run 500+ technical interviews from the hiring side over 15 years in mobile, and the migration question separates candidates quickly. Weak answers describe the destination: everything in SwiftUI, a new architecture, a clean slate. Strong answers describe the boundary that lets old and new run in the same release. The renderer is the easy part of a SwiftUI migration. Ownership, navigation and rollback are the work.

This article uses the method from my book Controlled Change, which has a migration playbook for exactly this case. Its rule for the coexistence period is one sentence: "Establish singular state ownership; both renderers observe the same feature state during coexistence." If you want the interview version of a migration answer, it is in the architecture migration interview answer. This one is the procedure.

  • The decision: what to migrate first, in what unit, and why.
  • The mechanics: an entry contract, UIHostingController as an adapter, representables with an update policy, one state owner.
  • The safety: one navigation authority, characterization before the swap, rollout per route, and the conditions for stopping.

Name the pressure before the framework

"Move to SwiftUI" is a destination, not a reason. A reason is something a product owner would recognize: the onboarding flow takes three weeks to change because its state is spread across four controllers, the settings area produces most accessibility defects, a new feature needs a screen that would be cheap in SwiftUI and expensive in UIKit.

The reason decides the order. A screen that is stable, correct and rarely touched gains almost nothing from being rewritten, whatever its age. A surface that changes every sprint, or that keeps producing incidents, pays back the migration on the next change. Start where change pressure is high and risk is moderate, not with the most central screen in the app.

Write the outcome as something you can measure: lead time for changes to one flow, defects in one area, time to build and preview one feature. "Percentage of SwiftUI" is not on that list. It measures activity, not leverage.

Choose the unit: screen, flow or module

The unit of migration is the thing you move behind one contract and can roll back as one step. There are three common candidates, and each has a different failure.

UnitGood whenTypical failure
ScreenA leaf screen with no outgoing navigation and simple stateState still lives in the presenting controller, so ownership never moves
FlowA journey with a clear entry, a clear outcome and few incoming dependenciesPicking a flow that crosses tabs, sheets and deep links on the first attempt
ModuleA feature already isolated behind a package or target boundaryRolling back means reverting a large change instead of flipping a flag

For a large app the flow is usually the right default. Controlled Change says to choose "one complete but bounded journey", and the reason is ownership: a flow has a beginning and an end, so it has state that belongs to it and an outcome it reports. A single screen often borrows its state from whoever presented it, and swapping its renderer leaves the borrowing in place.

Avoid horizontal units. "Convert all cells first" or "replace the design system components everywhere" touches every feature and finishes none, so nothing is ever ready to roll out or delete. If the module boundaries themselves are the question, settle that first; when to modularize a mobile app covers the cost of drawing them.

Keep the contract stable before you touch a view

The first step is not SwiftUI. It is a seam that permits two implementations. My book puts it in the navigation chapter: "A feature should expose a route or entry contract, not its internal view class. Composition resolves the contract to a platform presentation." Callers ask for a route and receive an outcome. They never learn which framework rendered it.

Swift
enum TripRoute: Hashable, Codable, Sendable {
    case tripDetail(id: String)
    case editTrip(id: String)
}

enum TripOutcome: Sendable {
    case saved(tripID: String)
    case cancelled
}

@MainActor
protocol TripFeatureEntry {
    func makeViewController(
        for route: TripRoute,
        onFinish: @escaping @MainActor (TripOutcome) -> Void
    ) -> UIViewController
}

Introduce the contract while the UIKit implementation is still the only one. Route every caller, including deep links and notification taps, through it. Only then add a second conformance. The route is a value with the minimum stable data needed to rebuild the destination, which is also what makes it safe to persist for restoration and to accept from a link.

Watch the scope. The playbook in my book ends with a warning against "using UI migration to justify an unrelated domain rewrite." Keep the domain, data and network layers as they are while the renderer changes, so a regression has one suspect instead of three.

SwiftUI inside UIKit: the hosting controller is an adapter

Apple describes UIHostingController as a UIKit view controller that manages a SwiftUI view hierarchy, available since iOS 13. You use it like any other view controller: present it, push it, or embed it as a child. You set the root view at creation and can replace it later through rootView.

Treat it as a thin adapter. It receives the route input and narrow dependencies, builds the feature model, and hands back a controller. It does not hold business logic, and nothing inside it holds a strong reference back to the coordinator that presented it; completion leaves as a value through a closure.

Swift
@MainActor
struct SwiftUIEditTripEntry: TripFeatureEntry {
    let makeModel: @MainActor (String) -> TripEditModel
    let fallback: TripFeatureEntry

    func makeViewController(
        for route: TripRoute,
        onFinish: @escaping @MainActor (TripOutcome) -> Void
    ) -> UIViewController {
        guard case let .editTrip(id) = route else {
            return fallback.makeViewController(for: route, onFinish: onFinish)
        }
        let screen = EditTripScreen(model: makeModel(id), onFinish: onFinish)
        let host = UIHostingController(rootView: screen)
        host.sizingOptions = [.preferredContentSize] // iOS 16 and later
        return host
    }
}

Two details that cause real defects. Sizing: sizingOptions arrived in iOS 16 and its default is the empty set, so a hosted view shown in a popover or a sheet that should follow its content needs the option set explicitly. Cells: for SwiftUI content inside a UIKit collection or table view cell, UIHostingConfiguration (iOS 16 and later) is the content configuration built for that job, which avoids managing a child hosting controller per cell.

UIKit inside SwiftUI: a representable needs an update policy

The other direction matters just as much, because some UIKit components should not be rewritten at all: a mature map, a text engine, a chart with years of edge cases. UIViewRepresentable and UIViewControllerRepresentable wrap them for use in SwiftUI.

The lifecycle is the part to get right. makeUIView creates the view once; updateUIView can run whenever SwiftUI state that feeds the wrapper changes, so it should apply only what actually changed. Apple's documentation is explicit on two more points: changes inside the UIKit view do not reach the rest of SwiftUI on their own, which is the coordinator's job, and SwiftUI controls the wrapped view's layout properties, so your code should not set its frame or bounds.

Swift
struct LegacyMapView: UIViewRepresentable {
    let pins: [MapPin]
    let onSelect: @MainActor (String) -> Void

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

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

    func updateUIView(_ control: LegacyMapControl, context: Context) {
        context.coordinator.onSelect = onSelect
        guard context.coordinator.appliedPins != pins else { return } // skip redundant work
        context.coordinator.appliedPins = pins
        control.show(pins)
    }

    static func dismantleUIView(_ control: LegacyMapControl, coordinator: Coordinator) {
        control.delegate = nil // stop callbacks into a view that is gone
    }

    @MainActor
    final class Coordinator: LegacyMapControlDelegate {
        var appliedPins: [MapPin]?
        var onSelect: @MainActor (String) -> Void
        init(onSelect: @escaping @MainActor (String) -> Void) { self.onSelect = onSelect }
        func mapControl(_ control: LegacyMapControl, didSelect pinID: String) { onSelect(pinID) }
    }
}

The diff in updateUIView keeps an unrelated parent update from redrawing the map. dismantleUIView clears the delegate so the coordinator does not keep receiving callbacks after the view is gone. For sizing, iOS 16 added sizeThatFits(_:uiView:context:), which lets the wrapper answer a proposed size instead of relying on whatever intrinsic size the UIKit view reports. Test it in a list and at large Dynamic Type sizes, not only in one preview.

One owner for state while two renderers exist

This is where most migrations quietly fail. The SwiftUI screen gets its own copy of the trip title, the UIKit header keeps the old one, and both write. Whichever callback fires last wins. Chapter 10 of Controlled Change states the rule that prevents it: "Designate one owner for each piece of mutable state." Everything else observes a projection or sends a command.

Swift
@MainActor
final class TripEditModel: ObservableObject {
    let tripID: String
    @Published private(set) var title: String
    @Published private(set) var isSaving = false
    @Published private(set) var errorMessage: String?
    private let save: @Sendable (String, String) async throws -> Void

    init(tripID: String, title: String,
         save: @escaping @Sendable (String, String) async throws -> Void) {
        self.tripID = tripID
        self.title = title
        self.save = save
    }

    func rename(to newTitle: String) { title = newTitle }

    func commit() async -> Bool {
        isSaving = true
        defer { isSaving = false }
        do {
            try await save(tripID, title)
            errorMessage = nil
            return true
        } catch {
            errorMessage = "Not saved yet. Your changes are still here."
            return false
        }
    }
}

// The legacy UIKit header observes the same model; it never writes to it.
final class LegacyTripHeaderView: UIView {
    private let titleLabel = UILabel()
    private var subscription: AnyCancellable?

    func bind(to model: TripEditModel) {
        subscription = model.$title.sink { [weak self] title in
            self?.titleLabel.text = title
        }
    }
}

The setters are private, so neither renderer can mutate the title except through rename(to:). The SwiftUI screen observes the model; the UIKit header subscribes to the same published value. ObservableObject is used here because both sides can observe it on the deployment targets a large app usually still supports; the point is the single writer, not the observation mechanism.

Ownership moves in steps. First the legacy model stays authoritative and an adapter projects its state to the new screen. Then the new feature model owns presentation state. Then domain operations move behind a stable interface. Last, the old observers are removed. Each step ships on its own. The deeper version of this argument, including identity and effect lifetimes, is in SwiftUI vs UIKit: architecture is state ownership.

Navigation: one authority per transition

Mixed navigation breaks in ways that tests rarely cover: a SwiftUI NavigationStack inside a UIKit navigation controller, a hosted screen that pushes a view controller it found through a global, a deep link that restores the UIKit version of a screen after the flag moved to SwiftUI.

The rule is one authority for each transition. During a migration that is usually the existing UIKit coordinator: the SwiftUI screen finishes with a typed outcome, and the coordinator decides what happens next. My book frames navigation as "an effect with policy": a feature may request a destination, but the shell decides whether it is allowed.

Swift
@MainActor
final class TripCoordinator {
    private let navigation: UINavigationController
    private let legacy: TripFeatureEntry
    private let migrated: TripFeatureEntry
    private let useSwiftUIEditTrip: Bool // read once, when the journey starts

    init(navigation: UINavigationController, legacy: TripFeatureEntry,
         migrated: TripFeatureEntry, useSwiftUIEditTrip: Bool) {
        self.navigation = navigation
        self.legacy = legacy
        self.migrated = migrated
        self.useSwiftUIEditTrip = useSwiftUIEditTrip
    }

    func start(_ route: TripRoute) {
        let entry = useSwiftUIEditTrip ? migrated : legacy
        let controller = entry.makeViewController(for: route) { [weak self] outcome in
            self?.finish(outcome)
        }
        navigation.pushViewController(controller, animated: true)
    }

    private func finish(_ outcome: TripOutcome) {
        navigation.popViewController(animated: true)
    }
}

Restoration deserves its own check. Persist the route value, never a view controller type or a live model, and on restore let the entry contract decide which renderer to build. That way a user who backgrounded the app on the UIKit screen and returns after the flag changed lands on a valid destination either way.

Characterize the old screen before you swap the renderer

A new screen can look identical and still differ: other analytics events, a lost VoiceOver action, a different focus order, a validation message that no longer appears. Screenshots do not catch those. Before the swap, capture what the old screen actually does.

  • Analytics contract: event names and properties for each user action, compared old against new in the same test run.
  • Accessibility: labels, traits, actions and focus order, plus screenshots at the largest Dynamic Type size.
  • State transitions: tests against the shared model, which now runs under both renderers and needs no UI at all.
  • Entry points: deep links, notification taps and restoration fixtures saved by the previous release.
  • Performance: a baseline for the journey on a lower-tier device, not only on the newest phone.

The book calls these characterization tests and is careful about what they mean: they are not an endorsement of the old behavior. They show which behaviors are intentional, which are accidental, and which nobody needs. Decide each one on purpose instead of discovering it in a support ticket.

Release: roll out per route, keep the old renderer compiled

Ship both implementations in the same binary and select one per route. The selection is read once when the journey starts. Controlled Change gives the reason in its chapter on flags: "Checkout should not switch implementation or price behavior halfway through because a background config refresh arrived." A flow that changes renderer mid-edit loses state or shows it twice.

Roll out like any risky change: internal users, a small cohort, then wider, with stop conditions written down before exposure. Useful ones for a renderer swap are crash and hang rates on the migrated route, completion of the flow, accessibility defects, and analytics parity. Because the UIKit path is still compiled, rolling back is a configuration change that reaches installed apps, not a new release that waits for review and adoption.

Track each route through explicit states: not started, compatible, partial rollout, default new, old disabled, old deleted. "Default new" is not the end. The flag, the adapter and the old controller still have to go, and flag cleanup has a cost of its own that belongs in the plan.

When to stop

A migration plan without a stop condition turns into a permanent program. The legacy chapter of my book lists the reasons to pause: field quality regresses outside budget, the bridge becomes the dominant complexity, platform or tooling support is insufficient, or parity work has lower value than keeping "a bounded legacy island".

That last one is common in large apps. A UIKit area that is stable, owned, and gains no new callers can stay UIKit for years, behind the same entry contract. So can a complex UIKit control behind a representable with a real update policy. The book's verdict: "Stopping a migration can be responsible architecture if the remaining boundary is owned and contained."

Signs it is time to stop or pause: each new route needs more bridge code than feature code, the team spends more time on parity than on product, or SwiftUI performance on a key screen needs workarounds that UIKit did not. None of these is a failure. They are evidence, and the plan should name in advance which evidence changes the decision.

Delete the bridge on a schedule

The last steps of the playbook are the ones teams skip: delete the old renderer and the temporary bridge, then simplify state adapters that no longer serve two frameworks. Until then the app carries two implementations of every migrated route, double the tests, and an adapter that someone has to understand during the next incident.

Give each route a deletion date tied to evidence: the new path has been the default for an agreed number of releases, no restoration fixture still points at the old screen, and the flag has not been flipped back. Put the deletion in the same plan as the migration, with an owner. The production view of what tends to break once SwiftUI is under real traffic is in Your SwiftUI app works. Production is where it starts lying to you.

The short version for a planning document: migrate flows, not file counts; keep one entry contract, one state owner and one navigation authority per transition; ship both renderers until the evidence says otherwise; and decide in advance what would make you stop.

Questions engineers ask about UIKit to SwiftUI migration

What is the best unit for a UIKit to SwiftUI migration?

A bounded flow: one journey with a clear entry, a clear outcome and few incoming dependencies, such as edit trip or a settings section. A single screen is often too small to change ownership, and a whole module is too large to roll back in one step. Move the flow behind a stable entry contract, then swap the renderer behind it.

Should SwiftUI or UIKit own navigation during the migration?

One of them, per transition, and usually the existing UIKit coordinator at first. The SwiftUI screen reports a typed outcome or route intent, and the shell decides what appears next. Two systems pushing onto the same stack is where restoration, deep links and back behavior start to disagree.

Do we have to finish the migration to 100 percent SwiftUI?

No. A mature UIKit control behind a well-tested representable, or a stable UIKit area with an owner and no new callers, can be the right end state. Stop when the bridge costs more than the remaining legacy area, or when parity work is worth less than the product time it consumes.

How do we roll back a SwiftUI screen after release?

Keep the UIKit implementation compiled behind the same entry contract, select the implementation with a flag read once when the journey starts, and rehearse flipping it before rollout. Delete the old renderer only after the new one has been the default for an agreed observation window.

Share this essay