Mobile Engineering

SwiftUI + UIKit in 2026: the pragmatic Observation bridge pattern for migrating screens without rewriting your app shell

A practical migration pattern for iOS teams: keep your UIKit app shell and navigation, embed SwiftUI “islands”, and share state through Observation without over-updating the UI.

You want to modernise an iOS app without rewriting the shell, breaking navigation, or betting the company on a big-bang SwiftUI move. In 2026, the most practical path is to keep UIKit where it’s already doing the job, embed SwiftUI for new or reworked screens, and bridge shared state through Observation in a way that doesn’t accidentally turn every state change into a full UI refresh.

Why “Observation bridge” is the default migration shape in 2026

Apple’s current guidance is explicit: SwiftUI is designed to sit alongside UIKit/AppKit, and incremental adoption is the intended strategy, not a compromise. In the WWDC26 session on using SwiftUI with AppKit and UIKit, Apple calls out Observation-based integration as a core enabler, including back-deployment switches and the fact it’s enabled by default in newer OS releases. (developer.apple.com)

The interesting bit isn’t “you can embed SwiftUI”. We’ve been doing that for years. The shift is that Observation has become the common modelling layer for both SwiftUI and non-SwiftUI UI updates, so you can stop running two parallel state systems (e.g. Combine ObservableObject on one side and callbacks/delegates on the other). (developer.apple.com)

That’s what I mean by an Observation bridge: one model, two UI worlds.

The pattern in one paragraph

  • Keep your UIKit app shell: UINavigationController / tab bar / custom coordinators, your existing routing, and your existing view controller lifecycle.
  • For each screen you modernise, embed SwiftUI using UIHostingController in that existing shell. (developer.apple.com)
  • Use @Observable models (or a small set of observable “stores”) as the shared state boundary.
  • In UIKit, let observation tracking re-render the minimal UI on changes; in SwiftUI, read only what you need in body so you don’t over-invalidate.

This gives you incremental UI modernisation without taking on a navigation rewrite at the same time.

What to migrate first (and what not to)

The fastest wins are screens that are UI-heavy but routing-light: settings, profile/edit forms, small dashboards, “empty states”, onboarding pages. The worst first candidates are screens where your current UIKit code is doing deep custom layout, complex interactive gestures, or tight performance work in collection views.

A simple decision checklist we use:

  • Migrate first if:
  • The screen is mostly standard components and layout.
  • The screen already consumes state from a service layer you trust.
  • The screen’s performance issues are mostly “too much UIKit code”, not “too much work per frame”.
  • You can define a clear input/output contract (see next section).
  • Do not migrate first if:
  • The screen uses heavy UICollectionView custom layout with lots of prefetching and cell reuse tricks.
  • You depend on very specific UIKit behaviours (e.g. focus engine quirks, complex text interactions) that you haven’t replicated in SwiftUI yet.
  • The screen is the navigation hub for half the app; keep the coordinator stable until you have momentum.

SwiftUI can absolutely handle complex work. The point is sequencing: don’t combine “new UI framework” and “core navigation / performance hotspot” in the same early iteration.

Define the bridge: inputs, outputs, ownership

Bridging fails when a SwiftUI island “reaches out” into UIKit for things it shouldn’t know about (navigation controllers, global singletons, view controller lifecycle), or when UIKit tries to micromanage SwiftUI state.

A clean bridge has:

  1. A model/store that is @Observable and owned by the UIKit side (because UIKit owns the screen lifetime in this pattern).
  2. Explicit inputs: dependencies and initial parameters passed into that store.
  3. Explicit outputs: closures, delegates, or event streams that the UIKit coordinator listens to.

Concretely, your UIKit coordinator creates the store and the hosting controller:

  • UIKit owns: navigation, lifetime, and dependency injection.
  • SwiftUI owns: rendering and local ephemeral UI state.
  • Store owns: screen state, validation, async loading, and user intents.

This aligns with Apple’s framing that Observation is a modelling layer you can adopt even before you move UI to SwiftUI. (developer.apple.com)

A minimal implementation sketch (UIKit shell, SwiftUI screen)

At a high level:

  1. Create a screen model: - @Observable final class EditProfileModel { ... } - Put screen state as plain stored properties. - Expose intent methods: saveTapped(), load(), updateName(_:).
  1. UIKit coordinator pushes a UIHostingController: - Create UIHostingController(rootView: EditProfileView(model: model)). Apple documents UIHostingController as the entry point for embedding SwiftUI in UIKit view hierarchies. (developer.apple.com)
  1. SwiftUI renders the model: - Read only the properties you need. - Use @Bindable where you genuinely need two-way bindings into the model.
  1. Model emits outputs: - On success/failure, call onFinished?(result). - UIKit handles navigation (pop, present, route elsewhere).

There are many variants. The non-negotiable is that UIKit still drives navigation, otherwise you’ve started a shell rewrite.

Avoid the two big performance traps: over-updates and tracking boundaries

Most “SwiftUI is slow” stories in real apps are not about SwiftUI being inherently slow; they’re about invalidating too much of the tree too often, and doing expensive work during those invalidations.

Two things matter with Observation:

1) Over-updates: you changed state that too many views read

Observation’s win is per-property access tracking: if a view didn’t read a property, changing it shouldn’t invalidate that view. That’s the structural behaviour change compared to the old ObservableObject + @Published pattern, and it is why migrations can get faster if you shape state well. (blakecrosley.com)

Failure mode:

  • You put “everything” in one big observable store.
  • Many views read “a little bit” from that store.
  • A single write touches a property that those views happen to read.
  • You effectively invalidate large parts of the screen.

Mitigation:

  • Split big stores into smaller ones aligned to UI regions.
  • Prefer value-type derived state outside body if it’s expensive to compute.
  • Measure the expensive part; don’t guess. A body re-run can be cheap, but expensive work hidden inside computed properties is often the real culprit. (theswift.dev)

2) Tracking boundaries: you accidentally read the wrong thing in the wrong scope

Observation tracking is driven by what properties are accessed in a given scope. If you accidentally read broad state at the top of a view (or inside a helper used everywhere), you enlarge the tracking surface area.

A practitioner-friendly way to think about it:

  • Treat every body as a dependency declaration.
  • If you read it in body, you are signing up to be invalidated when it changes.

In list-heavy screens, one common goal is to avoid a parent-level read that invalidates every row. The Swift Dev article on keeping an observable store from invalidating every row is a good illustration of shaping reads and tracking scopes, while also warning not to confuse property-tracking assertions with guaranteed frame-rate outcomes. (theswift.dev)

Layout adaptability: the migration risk nobody budgets for

Once you start embedding SwiftUI screens, you run into a different class of issue: UIKit shells often assume fixed navigation bar behaviour, predictable safe area edges, and explicit constraint-driven sizing. SwiftUI content, by contrast, may:

  • Size itself based on content and environment.
  • Reflow more aggressively (dynamic type, localisation, split view).
  • Behave differently when embedded in containers (hosting controller, sheet, popover).

The failure mode looks like this:

  • The screen looks fine in isolation.
  • Embedded in your existing UIKit container, you get clipped content, awkward scroll behaviour, or “mystery” spacing.

What to do about it:

  • Make sizing and scrolling explicit: decide whether the SwiftUI island owns scrolling (ScrollView) or UIKit does (e.g. embedding in a UIKit scroll view). Avoid double-scroll setups.
  • Be disciplined about safe areas and navigation bars: decide whether your SwiftUI view extends under bars or not, and test both large-title and inline configurations.
  • Test the embedding contexts early: push, modal, sheet, split view, and any custom container you use.

Also: if you use hosting controllers, follow Apple’s long-standing warning: don’t separate the hosting controller’s view from the hosting controller itself, because many SwiftUI features require connection to the view controller hierarchy. (developer.apple.com)

Tooling reality in the 2026 cycle

The 2026 toolchain is noticeably more “Observation-first” than the 2023–2024 era, and Apple’s WWDC26 material ties UIKit/AppKit integration to Observation tracking, including back-deployment knobs and default enablement in newer OS releases. (developer.apple.com)

Two practical implications for teams planning a migration:

  • Decide your minimum OS policy up front. If you’re still supporting older iOS versions, you may need opt-in flags and extra testing around observation tracking behaviour. Apple explicitly mentions Info.plist keys for back-deploying the integration. (developer.apple.com)
  • Don’t treat “compiles” as “safe”. Observation is sensitive to how you structure and access state; you need profiling and regression tests that cover the embed contexts you actually use.

When the right answer is “don’t do this”

There are cases where an Observation-bridged island is the wrong move:

  • You need a consistent cross-screen interaction model that is already deeply custom in UIKit, and rewriting just one screen in SwiftUI will create UX mismatch you can’t afford.
  • Your team cannot allocate time for performance instrumentation and state-graph refactoring. If you’re not prepared to reshape state, you’ll ship a screen that looks modern and feels sluggish.
  • You’re in the middle of a larger architectural rewrite (data layer, auth, offline) and you’re trying to modernise UI simultaneously. Pick one axis; otherwise you will keep invalidating the plan.

Incremental does not mean “free”. It means you’re choosing where to pay.

A short, honest note on how we help

At codeversols we tend to get called when a team wants incremental modernisation without losing delivery pace: keeping a UIKit shell stable, carving out SwiftUI islands, and putting proper Observation-based state boundaries in place. That work is less about “SwiftUI expertise” and more about engineering hygiene: defining contracts, measuring performance, and avoiding migration traps that are easy to miss when you’re trying to ship.

Close

If you want a pragmatic 2026 migration plan, keep UIKit navigation and embed SwiftUI where it buys you velocity, not where it increases risk. Use Observation as the shared language between the two worlds, but treat dependency tracking as a design constraint: what you read is what you will re-render. Get those boundaries right, and you can modernise screen-by-screen without rewriting your app shell.

More From Our Blog

Kotlin 2 + Compose compiler mismatch: the dependency trap that causes ‘random’ recomposition (and how to prove it in CI)
Mobile Engineering • Oct 20, 2026

Kotlin 2 + Compose compiler mismatch: the dependency trap that causes ‘random’ recomposition (and how to prove it in CI)

Mixed Compose compiler versions across modules and prebuilt dependencies can quietly disable skipping and inflate recompositions. Here’s a minimal repro, how to surface it from build artefacts, and a CI guardrail to stop regressions shipping.

Read more →
Android 16 edge-to-edge is enforced: the one screenshot you should diff in CI (and how to stop OEM inset regressions)
Mobile Engineering • Oct 19, 2026

Android 16 edge-to-edge is enforced: the one screenshot you should diff in CI (and how to stop OEM inset regressions)

Targeting Android 16 (API 36) removes the last edge-to-edge opt-out, so any latent inset assumptions become production bugs. Here’s a pragmatic CI gate: one golden screenshot with an insets overlay that catches IME, cutout and OEM window-insets drift before you ship.

Read more →
Swift Observation performance: auditing registrar contention when your model is fast but your dependencies aren’t
Mobile Engineering • Oct 18, 2026

Swift Observation performance: auditing registrar contention when your model is fast but your dependencies aren’t

Swift Observation can be plenty fast in isolation, yet still trigger stutters when app-level patterns cause high-frequency invalidations and lock contention. Here’s a profiler-first audit you can run in mixed SwiftUI/UIKit apps, plus the three refactors that reliably reduce contention and update fan-out.

Read more →