Craft · Architecture

Mixing UIKit and SwiftUI in an app older than SwiftUI

The biggest app I maintain shipped its first version in 2016, three years before SwiftUI existed. There is no rewrite coming. There shouldn't be. Yet every new screen I add is SwiftUI — and the two frameworks coexist happily, because interop is a strategy I commit to, not a hack I apologise for.

If you've shipped iOS apps for any length of time, you have at least one of these: a codebase with a storyboard older than some of your colleagues, a hand-rolled navigation stack, a tab bar wired up in AppDelegate, and view controllers that have been patched so many times nobody dares touch the layout. It works. It makes money. And rewriting it from scratch is how you spend a year shipping nothing and reintroducing bugs you fixed in 2019.

The good news is that Apple built UIKit and SwiftUI to talk to each other deliberately, in both directions, and the bridges are stable production tools rather than escape hatches. The skill isn't knowing the APIs — there are only a handful. It's deciding which way to cross the boundary, and resisting the urge to migrate things that don't need it.

Two bridges, opposite directions

There are really only two things to learn, and they mirror each other:

  • UIHostingController takes a SwiftUI View and hands you back a UIViewController. This is how you drop a new SwiftUI screen into an existing UIKit navigation or tab stack — the shell stays UIKit, the content is SwiftUI.
  • UIViewRepresentable and UIViewControllerRepresentable go the other way: they wrap a UIKit view or controller so it appears inside a SwiftUI hierarchy as if it were native. This is how you reuse a battle-tested UIKit component — a camera preview, an MKMapView, a custom-drawn control — from new SwiftUI code.

That's the whole vocabulary. Everything else is knowing which one to reach for, and that follows from one question: where does the screen live, and what owns it?

Dropping SwiftUI into a UIKit shell

For a long-lived app, this is the direction you'll use ninety percent of the time. The app's skeleton — the tab bar, the root navigation controller, the deep-link routing — stays exactly where it is. When you build a new settings screen, a new onboarding flow, a new detail view, you write it in SwiftUI and wrap it for presentation:

import UIKit
import SwiftUI

final class SettingsCoordinator {

    private let navigation: UINavigationController

    init(navigation: UINavigationController) {
        self.navigation = navigation
    }

    func showAppearanceSettings() {
        let view = AppearanceSettingsView(store: settingsStore)
        let host = UIHostingController(rootView: view)
        host.title = "Appearance"

        // It's just a UIViewController now — push it like any other.
        navigation.pushViewController(host, animated: true)
    }
}

From the navigation controller's point of view, host is an ordinary view controller. It pushes, pops, and shows up in the back stack with the right title. Your SwiftUI view doesn't know or care that its parent is a UIKit UINavigationController from 2016. The seam is invisible to users and almost invisible to the code on either side.

One detail that trips people up: by default a UIHostingController draws its own background, which can fight with a translucent navigation bar or a custom tab background. If your SwiftUI content is meant to sit on the system background, that's fine; if you've got a custom-coloured shell, set host.view.backgroundColor = .clear after creating it and let the UIKit chrome show through.

Navigation ownership

Pick one navigation system per flow and let it win. If a UIKit UINavigationController owns the stack, push hosting controllers into it and do not also wrap your SwiftUI screens in a NavigationStack — you'll get double bars and two competing back buttons. SwiftUI screens inside a UIKit stack should be plain content; the chrome belongs to UIKit.

Bringing UIKit into SwiftUI

The other direction comes up less often but matters more when it does, because the components you want to reuse are usually the ones that took weeks to get right. Camera preview layers, an MKMapView with a decade of annotation logic, a signature-capture control, a video player with exact buffering behaviour — none of these have an equally mature SwiftUI equivalent, and reimplementing them is pure risk with no upside.

UIViewRepresentable wraps a single view. You implement makeUIView to create it once, updateUIView to push SwiftUI state changes into it, and — the part people skip — a Coordinator to carry events back out. The coordinator is how a UIKit delegate callback becomes something SwiftUI can react to. Here's a map view that reports the region back to its SwiftUI parent:

import SwiftUI
import MapKit

struct RouteMapView: UIViewRepresentable {

    let route: [CLLocationCoordinate2D]
    @Binding var visibleRegion: MKCoordinateRegion

    func makeUIView(context: Context) -> MKMapView {
        let map = MKMapView()
        map.delegate = context.coordinator          // delegate → Coordinator
        return map
    }

    func updateUIView(_ map: MKMapView, context: Context) {
        // SwiftUI state changed — reconcile the UIKit view.
        map.removeOverlays(map.overlays)
        map.addOverlay(MKPolyline(coordinates: route, count: route.count))
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    final class Coordinator: NSObject, MKMapViewDelegate {
        private let parent: RouteMapView
        init(_ parent: RouteMapView) { self.parent = parent }

        // UIKit callback → write back into the SwiftUI binding.
        func mapView(_ map: MKMapView, regionDidChangeAnimated: Bool) {
            parent.visibleRegion = map.region
        }

        func mapView(_ map: MKMapView,
                     rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
            let r = MKPolylineRenderer(overlay: overlay)
            r.strokeColor = .systemGreen
            r.lineWidth = 4
            return r
        }
    }
}

The mental model that keeps this clean: makeUIView runs once, updateUIView runs every time the SwiftUI state it depends on changes. So all your one-time setup — delegate assignment, registering reuse identifiers, configuring gestures — goes in make. Anything that reflects current state goes in update, and it has to be idempotent, because it'll be called more often than you expect. The coordinator is the only object that outlives a single update pass, which is why delegates and target-action handlers have to live there.

UIViewControllerRepresentable is the same shape for a whole view controller — reach for it when the thing you're wrapping is already a controller with its own lifecycle, like UIImagePickerController, an AVPlayerViewController, or one of your own legacy screens you want to surface inside a new SwiftUI flow.

Sharing state across the seam

Bridging views is the easy half. The half that decides whether the codebase stays sane is state, because a screen is useless if it can't read and write the same data as the rest of the app. You have three tools, and they layer:

  • An ObservableObject (or @Observable class) as the shared source of truth. Because it's a reference type, the same instance can be handed to a SwiftUI view via @StateObject/@ObservedObject and held by a UIKit view controller. Both sides observe it; both sides mutate it; neither owns it.
  • Combine or async sequences for the UIKit side to listen. A view controller can sink on a published property, or iterate an AsyncStream, and update its own views imperatively when the shared model changes. This is how a legacy screen reacts to something a new SwiftUI screen did.
  • The Coordinator for event callbacks in the SwiftUI-wraps-UIKit direction, as in the map above — delegate methods write straight back into a @Binding.

The principle underneath all three: don't try to push view state across the boundary, push model state. The moment you find yourself reaching into a hosting controller to read a SwiftUI @State, or poking a UIKit view's properties from SwiftUI, stop — that data wants to live in a shared model that both sides observe. Keep the seam at the model layer and the views on each side stay blissfully ignorant of each other.

The pitfalls nobody warns you about

Most interop pain clusters around a few mismatches between the two frameworks' assumptions:

  • Intrinsic sizing. SwiftUI asks a view how big it wants to be; UIKit views don't always have a confident answer. A UIViewRepresentable with no intrinsic content size can collapse to zero height inside a VStack. Give the wrapped view real layout constraints, or implement sizeThatFits in newer SDKs, rather than wondering why your control vanished.
  • Lifecycle mismatch. UIKit's viewDidAppear/viewWillDisappear and SwiftUI's onAppear/onDisappear do not fire in lockstep, especially with hosting controllers nested in containers. Don't assume a hosting controller's appearance callbacks line up with the SwiftUI view's. Drive lifecycle work from whichever framework actually owns the screen.
  • Safe area and keyboard. A hosting controller usually respects safe areas, but a representable wrapping a full-bleed view (a camera preview, a map) may need ignoresSafeArea() on the SwiftUI side, while the UIKit view still wants its own inset handling. Decide which layer owns the insets and disable the other.
  • Navigation, again. Worth repeating because it's the most common bug: two navigation systems fighting over the same stack. One owner per flow.

Choosing the direction, and when to stop

The decision rule I use is boring and reliable. New screens are SwiftUI, hosted in the existing UIKit shell. That's the default, and it's where the productivity win lives — declarative layout, previews, less boilerplate. You go the other way, wrapping UIKit in SwiftUI, only when a specific component is mature, fiddly, and has no good SwiftUI replacement; reusing it is cheaper and safer than rebuilding it.

Migrate leaf-first: the screens at the tips of the navigation tree, the ones with the fewest things depending on them, are where SwiftUI pays off fastest and the blast radius of a mistake is smallest. Leave the shell — the tab bar, the root coordinator, the deep-link router — in UIKit for a long time, possibly forever. The shell is plumbing; rewriting plumbing that works buys you nothing a user will ever notice.

And knowing when to stop is its own discipline. A screen that's stable, rarely touched, and free of bugs has no business being migrated just to make the codebase uniform. "All SwiftUI" is not a goal; a shipping app that's cheap to change is the goal. I have UIKit view controllers in production that I haven't opened in two years, and that's exactly as it should be — they earn their keep without costing me attention.

So treat interop as the architecture, not a transitional embarrassment. The two frameworks will share an app for the rest of that app's life, and that's fine — Apple ships them side by side for the same reason. Pick one owner per navigation flow, keep shared state in a model both sides observe, bridge in whichever direction the component argues for, and migrate only where it pays. Do that and the question stops being "when do we finish the rewrite" and becomes "what's the next screen" — which is a much better question to be answering.

← All writing