Skip to content
Blog

SwiftUI vs. UIKit: Building Modern iOS Apps

A practical comparison of SwiftUI and UIKit in 2026 — declarative vs imperative, when to use each, and how to mix both in a hybrid app with UIViewRepresentable.

Published on August 14, 2026

AI Assistant

SwiftUI vs. UIKit: Building Modern iOS Apps

You’ve just been handed a greenfield iOS project. The first question on every developer’s mind — and every architecture review meeting — is the same: SwiftUI or UIKit? Meanwhile, your teammate is arguing that the existing codebase, seven years old and 200,000 lines of UIKit, should never have touched SwiftUI in the first place. Both camps are partially right, and that’s exactly what makes this decision hard.

In this tutorial, you will learn how to compare SwiftUI and UIKit from a practical, code-first perspective in 2026. You’ll see the declarative paradigm of SwiftUI (View, @State, body, previews) side by side with UIKit’s imperative model (UIViewController, viewDidLoad, Auto Layout), understand when each framework is genuinely the right call, and — most importantly — learn how to mix both in a single app using UIViewRepresentable and UIViewControllerRepresentable.

Key technologies: SwiftUI, UIKit, the Observation framework (@Observable), UIViewRepresentable/UIViewControllerRepresentable, Auto Layout.

Prerequisites

  • Xcode 15 or newer (the examples use #Preview, available from Xcode 15)
  • A working knowledge of Swift (structs, protocols, optionals)
  • Basic familiarity with iOS app lifecycle concepts (scenes, view controllers, views)
  • No prior SwiftUI or UIKit experience required — we’ll start from the API surface

Two Ways to Think About a Screen

The fundamental difference between the two frameworks is not API names — it’s a paradigm. UIKit is imperative: you build a view hierarchy, and then you mutate it in response to events. SwiftUI is declarative: you describe what the UI should be for a given state, and the framework figures out how to get there.

UIKit: The Imperative Model

UIKit provides the window and view architecture, the event-handling infrastructure for Multi-Touch input, and the main run loop that manages interactions between the user, the system, and your app. The heart of a screen is a UIViewController, whose viewDidLoad() method is your hook to build the interface once.

Here’s the same “counter” screen in UIKit:

import UIKit

final class CounterViewController: UIViewController {
    private var count = 0
    private let label = UILabel()
    private let button = UIButton(type: .system)

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground

        label.text = "Count: 0"
        label.textAlignment = .center

        button.setTitle("Increment", for: .normal)
        button.addTarget(self, action: #selector(incrementTapped), for: .touchUpInside)

        let stack = UIStackView(arrangedSubviews: [label, button])
        stack.axis = .vertical
        stack.spacing = 16
        stack.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(stack)

        NSLayoutConstraint.activate([
            stack.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            stack.centerYAnchor.constraint(equalTo: view.centerYAnchor)
        ])
    }

    @objc private func incrementTapped() {
        count += 1
        label.text = "Count: \(count)"
    }
}

Walk through what’s happening: you create UILabel and UIButton instances, wire the button to an action using addTarget(_:action:for:), place them in a UIStackView, and manually constrain the layout with Auto Layout (translatesAutoresizingMaskIntoConstraints = false, then NSLayoutConstraint.activate). When the button is tapped, incrementTapped() is called and you imperatively update label.text. Every piece of state change is a manual mutation you must remember to perform.

SwiftUI: The Declarative Model

SwiftUI provides views, controls, and layout structures for declaring your app’s interface. Your screen is a View — a struct with a computed body that returns some View. State is stored in property wrappers like @State, and SwiftUI automatically recomputes the affected parts of the hierarchy when that state changes.

The same counter in SwiftUI:

import SwiftUI

struct CounterView: View {
    @State private var count = 0

    var body: some View {
        VStack(spacing: 16) {
            Text("Count: \(count)")
            Button("Increment") {
                count += 1
            }
        }
    }
}

#Preview {
    CounterView()
}

The differences are stark. There’s no lifecycle hook to build the UI — body is the UI at any moment. There’s no layout engine configuration; VStack handles it. And crucially, you never manually push text into a label. You read count in the body, and when count changes SwiftUI re-renders the views that depend on it. The #Preview macro renders a live, interactive preview right inside Xcode.

State and Lifecycle: The Real Differentiator

The biggest mental shift when moving from UIKit to SwiftUI isn’t syntax — it’s who owns state and who drives updates.

UIKit: Delegates and Callbacks

UIKit is a delegate-driven framework. A UITableView doesn’t know about your data; you conform to UITableViewDataSource and UITableViewDelegate to supply cells and react to selection. A UISlider reports value changes through a target-action callback. Communication between objects happens through delegation patterns, notifications, and closures. This gives you explicit control but also explicit bookkeeping — forget to call tableView.reloadData() and the screen is stale.

SwiftUI: Declarative State and the Observation Framework

SwiftUI inverts this. Instead of pushing data into views, views derive from state. For local view state, use @State:

@State private var isPlaying = false

SwiftUI manages the property’s storage; when it changes, SwiftUI updates the parts of the view hierarchy that depend on it. To let a child read and write that value, you pass a Binding using the $ prefix.

For shared model data, modern SwiftUI (iOS 17+) uses the Observation framework. Annotate a class with @Observable, hold it in @State, and pass the reference down — views re-render only when they actually read a changed property:

import Observation

@Observable
final class PlayerModel {
    var isPlaying = false
    var title = "Deep Work Sessions"
    var elapsed: Double = 0
}

struct PlayerView: View {
    @State private var player = PlayerModel()

    var body: some View {
        VStack {
            Text(player.title)
            Toggle("Playing", isOn: $player.isPlaying)
        }
    }
}

Note the @Bindable detail: when you pass a reference to an @Observable object into a subview and need a binding to one of its properties (like $player.isPlaying above), the subview wraps the property with @Bindable. Contrast this with UIKit, where you’d wire a target-action and set the object’s property yourself.

When to Use Which in 2026

By 2026 the “SwiftUI isn’t production-ready” argument is long dead. Compose and SwiftUI-style declarative UIs are the default for new Apple platforms, and SwiftUI is genuinely the right choice for most new apps. But “most” is not “all”.

Choose SwiftUI when:

  • You’re starting a new app or a new feature in an existing app.
  • You value rapid iteration — live previews, fewer lines of code, less layout boilerplate.
  • You need multi-platform reach. SwiftUI code ports across iOS, iPadOS, macOS, tvOS, watchOS, and visionOS with the same View semantics. UIKit is iOS/iPadOS/tvOS only (and via Mac Catalyst).
  • Your team is comfortable with reactive/declarative thinking.

Choose UIKit when:

  • You have a large existing legacy codebase. Rewriting stable UIKit into SwiftUI is expensive and risky with little user-visible payoff.
  • You need the deepest platform control: complex custom-drawn interfaces, heavy table-view reuse patterns, fine-grained Auto Layout, or TextKit-based custom text rendering. UIKit still exposes APIs with no SwiftUI equivalent.
  • Your team’s skill set is UIKit-centric and the app is a mature, slow-moving product.
  • You’re extending a long-lived framework feature (a UIKit UICollectionView compositional layout) that has no SwiftUI parity.

The realistic 2026 guidance: start in SwiftUI, drop to UIKit only when you must, and treat the two as cooperative rather than exclusive.

Interoperating: Bringing UIKit into SwiftUI

The bridge between the two worlds is the UIViewRepresentable protocol. Adopt it in a struct to wrap a UIKit UIView and drop it into a SwiftUI hierarchy. The protocol requires makeUIView(context:) (create and configure) and updateUIView(_:context:) (sync state from SwiftUI). A Coordinator — created in makeCoordinator() — handles delegate/target-action callbacks from the UIKit view back into SwiftUI.

import SwiftUI
import UIKit

// A SwiftUI wrapper around UIActivityIndicatorView
struct ActivityIndicator: UIViewRepresentable {
    let isAnimating: Bool

    func makeUIView(context: Context) -> UIActivityIndicatorView {
        UIActivityIndicatorView(style: .medium)
    }

    func updateUIView(_ uiView: UIActivityIndicatorView, context: Context) {
        isAnimating ? uiView.startAnimating() : uiView.stopAnimating()
    }
}

struct LoadingScreen: View {
    @State private var loading = true

    var body: some View {
        VStack {
            ActivityIndicator(isAnimating: loading)
            Text(loading ? "Syncing…" : "Ready")
        }
    }
}

makeUIView runs once; updateUIView runs whenever SwiftUI state that the view reads changes. This is exactly how SwiftUI’s own bridge-backed views (like Map) work under the hood. One warning from Apple’s docs: SwiftUI fully controls the frame, bounds, center, and transform of the wrapped view — never set those directly on the UIKit view.

The sibling protocol UIViewControllerRepresentable wraps a whole UIViewController, using makeUIViewController(context:) and updateUIViewController(_:context:). Use it when you need to embed a legacy screen, a UINavigationController-based flow, or a UIImagePickerController inside a SwiftUI NavigationStack.

The reverse: SwiftUI inside UIKit

The bridge goes both ways. In a UIKit app, wrap a SwiftUI view with UIHostingController(rootView:) and present or embed it:

import SwiftUI

final class SettingsViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        let hosting = UIHostingController(rootView: SettingsView())
        addChild(hosting)
        hosting.view.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(hosting.view)
        hosting.didMove(toParent: self)

        NSLayoutConstraint.activate([
            hosting.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            hosting.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            hosting.view.topAnchor.constraint(equalTo: view.topAnchor),
            hosting.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
    }
}

This is the standard migration path: you adopt SwiftUI feature-by-feature in a UIKit app without a big-bang rewrite.

Putting It All Together

Let’s build a small hybrid app that demonstrates the pattern realistically: a SwiftUI list screen that wraps a UIKit date picker (via UIViewRepresentable) and a UIKit-styled list cell, all driven by an @Observable model.

import SwiftUI
import UIKit

// 1. Shared model — Observation framework
@Observable
final class TaskStore {
    var tasks: [String] = ["Review PR", "Update docs", "Ship build"]
    var selectedDate = Date()
}

// 2. UIKit widget wrapped for SwiftUI
struct DatePickerField: UIViewRepresentable {
    @Bindable var store: TaskStore

    func makeUIView(context: Context) -> UIDatePicker {
        let picker = UIDatePicker()
        picker.datePickerMode = .date
        picker.addTarget(context.coordinator,
                         action: #selector(Coordinator.changed(_:)),
                         for: .valueChanged)
        return picker
    }

    func updateUIView(_ uiView: UIDatePicker, context: Context) {
        uiView.date = store.selectedDate
    }

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

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

        @objc func changed(_ sender: UIDatePicker) {
            parent.store.selectedDate = sender.date
        }
    }
}

// 3. SwiftUI screen composing both worlds
struct TasksView: View {
    @State private var store = TaskStore()
    @State private var newTask = ""

    var body: some View {
        NavigationStack {
            List {
                Section("Due date") {
                    DatePickerField(store: store)
                }
                Section("Tasks") {
                    ForEach(store.tasks, id: \.self) { task in
                        HStack {
                            Image(systemName: "checkmark.circle")
                            Text(task)
                        }
                    }
                }
                Section {
                    HStack {
                        TextField("New task", text: $newTask)
                        Button("Add") {
                            store.tasks.append(newTask)
                            newTask = ""
                        }
                    }
                }
            }
            .navigationTitle("Tasks")
        }
    }
}

#Preview {
    TasksView()
}

Expected output: A navigation-based list with three sections. The top section shows a native UIDatePicker rendered through the SwiftUI hierarchy; changing the date updates store.selectedDate via the coordinator’s target-action, and the @Bindable wrapper keeps the picker’s date in sync if SwiftUI state changes elsewhere. The middle section lists tasks from the @Observable model; typing a task and tapping Add appends to the array, and the List recomposes automatically — no reloadData() call anywhere.

Conclusion & Next Steps

SwiftUI and UIKit aren’t rivals — they’re two layers of the same platform. SwiftUI is the default for new code in 2026, offering declarative state management, live previews, and cross-platform reach. UIKit remains essential for legacy code, deep platform control, and teams with strong imperative habits. The UIViewRepresentable/UIViewControllerRepresentable bridge plus UIHostingController means you never have to make a one-time “all or nothing” decision.

Next steps: build the Apple Landmarks sample (the canonical SwiftUI intro), then practice the bridge by wrapping a real UIKit component (a WKWebView or MKMapView) in a SwiftUI app, and finally try UIViewControllerRepresentable to embed an existing legacy screen in a new SwiftUI flow.

References / Sources