Skip to main content
All articles

SwiftUI vs UIKit: A 2026 Answer, Not a 2020 One

Ben Van AkenCo-Founder & CTO12 min read

Almost everything written about this compares a 2019 framework to a 2008 one and concludes, reasonably, that the older one is safer. That comparison stopped being useful somewhere around iOS 17.

Here is the answer we give clients in 2026: it is not a choice. Every non-trivial app we ship contains both, the interesting question is the ratio, and the ratio is set by the app rather than by taste. What follows is how we decide it, what SwiftUI still handles badly, and the interop that makes the mixture cheap.

Quick answer: is SwiftUI a framework?

Yes. SwiftUI is a UI framework from Apple, shipped as part of the platform SDKs, first released in 2019. You import it like any other framework and it gives you view types, layout, animation and state management.

The difference from UIKit is not what it is but how you use it. UIKit is imperative: you create view objects and mutate them over time. SwiftUI is declarative: you describe what the interface should look like for a given state, and the framework works out what to change on screen when that state moves. SwiftUI also spans every Apple platform from one codebase, where UIKit is iOS, iPadOS and tvOS with AppKit as its Mac counterpart.

What changed since the last comparison you read

If your mental model of SwiftUI dates from the era of ObservableObject and NavigationView, it is out of date in ways that change the decision:

  • Observation. The @Observable macro replaced ObservableObject and the pile of @Published wrappers. Views now depend on the individual properties they read, which removed a whole class of over-invalidation that used to make big SwiftUI screens feel slow.
  • Navigation. NavigationStack with a path binding replaced NavigationView, so deep links and programmatic navigation are ordinary value manipulation instead of a stack of bindings and hacks.
  • Scrolling. scrollPosition, scrollTargetBehavior and onScrollGeometryChange closed the biggest capability gap of the early years — the one that used to force a UIScrollView back into your view tree.
  • Concurrency. Swift 6 makes main-actor isolation explicit, and SwiftUI is designed around it. The framework benefits more than UIKit here, because the model layer it talks to is normally where the concurrency lives.
  • The design system. iOS 26's Liquid Glass has first-class support on both sides — glassEffect and GlassEffectContainer in SwiftUI, UIGlassEffect in UIKit — and standard controls in both frameworks adopt it when you build against the new SDK.
  • The gaps that used to be dealbreakers. WebKit now ships a real SwiftUI WebView, so wrapping WKWebView by hand is no longer the rite of passage it was.

The practical consequence: the list of things you must drop to UIKit for is shorter every year, and it is now short enough that most new apps start as SwiftUI apps without anybody arguing about it.

Where SwiftUI is simply the better tool

  • Anything form-shaped. Settings, onboarding, filters, profile editors. What is 300 lines of table view delegate is 40 lines of Form, and it gets Dynamic Type, dark mode and the current design language for free.
  • State-driven interfaces. If the screen is a function of a loading/loaded/failed enum, the declarative model is a direct expression of that and the imperative one is a set of instructions for getting between states.
  • Animation. Implicit animation on state change, matchedGeometryEffect for transitions, and phase animators cover in a line what used to be an animation block and a layout pass.
  • Multiplatform. One view layer across iPhone, iPad, Mac, Watch and Vision. It is never quite free, but it is not comparable to maintaining UIKit and AppKit versions of the same screen.
  • Speed of iteration. Previews, hot-reloaded layout, and small composable views mean a designer sitting next to an engineer can go through five variants in an hour.

Where UIKit still wins

This is the part most comparisons skip or hedge. These are the cases where we still reach for UIKit deliberately in 2026:

  • Complex collection layouts. UICollectionViewCompositionalLayout, and especially a custom UICollectionViewLayout, still do things SwiftUI's grids and lazy stacks cannot: interlocking tiles, pinned supplementary views, layouts that depend on neighbouring cells.
  • Precise scrolling and paging. When the requirement is written in terms of content offsets, deceleration and snap points — a media browser, a photo viewer, a page-based reader — UIScrollView gives you the direct control that SwiftUI deliberately abstracts.
  • Deep text editing. Anything that needs the text container, custom input views, fine-grained selection handling or a text engine you drive yourself is still a UITextView job.
  • Camera and media UI. AVFoundation is UIKit-shaped, and preview layers, focus interaction and orientation handling are all easier where the view is an object you own.
  • Very large legacy codebases. If you have 400 view controllers, a coordinator pattern and a team that knows it, a rewrite is not an upgrade. Adding SwiftUI at the edges is.
  • Third-party SDKs that vend view controllers. Payment sheets, identity checks, map SDKs, chat widgets. You will be hosting a UIViewController either way.

SwiftUI is better at the ninety percent of screens that are lists, forms and state. UIKit is better at the ten percent that are physics, pixels and text engines.

Interop, direction one: UIKit inside SwiftUI

The bridge is UIViewRepresentable or UIViewControllerRepresentable. Three methods, and one rule that trips everyone up: makeUIView is called once, updateUIView is called every time the SwiftUI state it reads changes, and updates flow one way. Anything the UIKit view wants to tell SwiftUI goes back through the Coordinator.

Swift
struct RichTextEditor: UIViewRepresentable {
    @Binding var text: String

    func makeUIView(context: Context) -> UITextView {
        let view = UITextView()
        view.delegate = context.coordinator
        view.font = .preferredFont(
            forTextStyle: .body
        )
        return view
    }

    func updateUIView(
        _ view: UITextView,
        context: Context
    ) {
        // Guard, or every keystroke round-trips
        // and the selection jumps to the end.
        if view.text != text {
            view.text = text
        }
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(text: $text)
    }

    final class Coordinator:
        NSObject, UITextViewDelegate
    {
        private let text: Binding<String>

        init(text: Binding<String>) {
            self.text = text
        }

        func textViewDidChange(
            _ view: UITextView
        ) {
            text.wrappedValue = view.text
        }
    }
}

Gestures got their own bridge in iOS 18. If you need UIKit's recogniser behaviour — simultaneous recognition, failure requirements, the arbitration rules an existing UIKit screen already depends on — you no longer have to wrap a whole view to get it:

Swift
struct PressAndHold:
    UIGestureRecognizerRepresentable
{
    let onFire: () -> Void

    func makeUIGestureRecognizer(
        context: Context
    ) -> UILongPressGestureRecognizer {
        UILongPressGestureRecognizer()
    }

    func handleUIGestureRecognizerAction(
        _ recognizer:
            UILongPressGestureRecognizer,
        context: Context
    ) {
        if recognizer.state == .began {
            onFire()
        }
    }
}

Interop, direction two: SwiftUI inside UIKit

This is the direction that matters for existing apps, and it has three altitudes.

A screen: UIHostingController

UIHostingController is a UIViewController wrapping a SwiftUI view, so it pushes, presents and embeds like anything else. The whole migration story for a large app is: replace one view controller at a time, keep the navigation in UIKit until it is the last thing left.

Swift
let settings = UIHostingController(
    rootView: SettingsScreen(store: store)
)
settings.title = "Settings"
navigationController?.pushViewController(
    settings,
    animated: true
)

A cell: UIHostingConfiguration

Since iOS 16 a collection or table cell's content can be a SwiftUI view directly, with no hosting controller and no manual constraint work. For an app whose complexity is in the layout but whose cells are ordinary, this is the highest-value fifty lines you can change — you keep the compositional layout and write the cells declaratively.

Swift
cell.contentConfiguration = UIHostingConfiguration {
    HStack(spacing: 12) {
        Avatar(url: item.avatarURL)
        VStack(alignment: .leading) {
            Text(item.title).font(.headline)
            Text(item.subtitle)
                .font(.subheadline)
                .foregroundStyle(.secondary)
        }
    }
}

A whole scene: UIHostingSceneDelegate

iOS 26 added scene-level hosting, which is the piece that was missing for staged migrations. A UIKit app can hand an entire scene to a SwiftUI Scene without adopting the SwiftUI app lifecycle everywhere:

Swift
final class ReaderSceneDelegate:
    UIResponder,
    UIHostingSceneDelegate
{
    @SceneBuilder
    static var rootScene: some Scene {
        WindowGroup {
            ReaderRoot()
        }
    }
}

There is also a smaller bridge worth knowing: since iOS 18 you can drive UIKit views with a SwiftUI animation curve, which is what keeps a half-migrated screen from having two different animation personalities.

Swift
UIView.animate(.spring(duration: 0.4)) {
    banner.alpha = 0
    banner.transform = .init(
        translationX: 0,
        y: -24
    )
}

The costs of mixing, which are real

  • Two state systems. UIKit state lives in objects, SwiftUI state lives in values. Every boundary you create is a place where the two must be kept in sync, and every one of those is a potential source of stale UI.
  • Sizing. A hosting controller's view has an intrinsic size derived from SwiftUI's layout, and embedding it in Auto Layout without thinking produces cells that are the wrong height until something invalidates them.
  • Safe areas and insets. They are handled at the boundary, and a hosted view inside a container that already applied insets will apply them twice. Look here first when the padding is mysteriously doubled.
  • Debugging. The view hierarchy debugger understands UIKit far better than it understands SwiftUI's internals, so the mixed hierarchy is opaque in exactly the places you want to inspect.
  • Hiring and review. A codebase with two idioms needs engineers comfortable in both, and a review culture that does not litigate the choice on every pull request.

None of these are arguments against mixing. They are arguments for mixing at deliberate seams — a screen, a cell, a scene — rather than wherever it happened to be convenient.

How to choose, per project

  1. New app, iOS 17 or later, standard product surfaces: SwiftUI first, and drop to UIKit only where the list above says to. This is most new work.
  2. New app with a hard requirement in the UIKit column — a custom media viewer, a document editor, a camera-centric product: build that surface in UIKit from day one and put SwiftUI everywhere else. Do not try to talk yourself out of it in month three.
  3. Existing UIKit app, active roadmap: keep UIKit navigation, migrate leaves. New screens ship as hosting controllers, cells move to UIHostingConfiguration, and the rewrite never appears on the roadmap as a line item.
  4. Existing UIKit app, feature-frozen: leave it alone. Framework choice is not a business case on its own.
  5. Multiplatform product — iPhone, iPad and Mac from one team: SwiftUI, weighted heavily. This is where it pays for itself fastest.
  6. Deployment target below iOS 16: your SwiftUI is missing NavigationStack, Observation and the scroll APIs, which is most of what makes it good. Be honest about that in the estimate.

The migration order that works

We have done this on several codebases, and the order matters more than the pace.

  1. Start with a leaf screen nobody is precious about — a settings page, an about screen. It proves the toolchain and the review process, not the architecture.
  2. Move the model layer to @Observable next, before more views. SwiftUI views on top of an ObservableObject built for UIKit inherit that model's invalidation behaviour, and it is the reason many first attempts feel sluggish.
  3. Then the cells, via UIHostingConfiguration. High volume, low risk, and it removes the most boilerplate per hour spent.
  4. Then whole screens, one hosting controller at a time.
  5. Navigation last, and only when almost nothing is left in UIKit. Converting to NavigationStack while half your screens are view controllers means owning two navigation systems and the bugs where they disagree.

The short version

SwiftUI is a framework, it is the default for new work in 2026, and it is not a replacement for UIKit so much as the layer you write most of your app in while UIKit handles the parts that are about pixels, physics and text. Anyone who tells you to pick one and commit has not shipped an app with a camera in it.

Choose SwiftUI unless something on the UIKit list is central to your product. Bridge at deliberate seams. And if you are migrating, move the model layer before you move the views — that is the step everybody skips and the one that determines whether the result feels fast.

Weighing up a SwiftUI migration?

We have taken UIKit codebases across without a rewrite, and shipped SwiftUI apps from scratch. Book a call and let's look at yours.