Skip to content
Commitwise
Menu
iOS Development

UIKit for SwiftUI developers

Read and write the UIKit code most iOS apps still contain, with view controllers and their lifecycle, Auto Layout in code, collection views with diffable data sources, delegates and target-action, and bridge both ways with UIViewRepresentable and UIHostingController without update loops or leaks.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

You learned iOS with SwiftUI, and your first job’s app is ten years old: 300 view controllers, a few SwiftUI screens added last year, and a map view that SwiftUI can’t do the way the designers want. Your first ticket is “the order list flickers when an order updates”, in a UICollectionView.

Almost every production iOS app mixes the two frameworks, and SwiftUI itself still leans on UIKit for some things. This tutorial covers what you need to work in UIKit code confidently, mapped to the SwiftUI ideas you already know, and how to put each framework inside the other.

New to some of these? Open what you need, or skip ahead.

Basics:Declarative and imperative UI

SwiftUI is declarative: you describe what the screen should look like for the current state, and the framework updates it. UIKit is imperative: you create views once and then change them yourself, step by step, whenever something changes (label.text = "3 items").

Basics:What is a delegate?

A delegate is an object that another object calls to ask questions or report events: a table view asks its delegate “what happens when row 3 is tapped?”. It’s UIKit’s main way of letting your code customize a built-in component, similar to the closures you pass to SwiftUI views.

Part Not started

View controllers, layout and lists

Here’s how SwiftUI ideas map to UIKit:

SwiftUIUIKit
A View struct for a screenA UIViewController subclass
Text, Button, ImageUILabel, UIButton, UIImageView
VStack, HStackUIStackView, or Auto Layout constraints
ListUICollectionView (or the older UITableView)
NavigationStackUINavigationController
.onAppearviewWillAppear / viewDidAppear
State changes redraw the viewYou update the views yourself

A view controller’s lifecycle methods are called by UIKit at fixed moments: viewDidLoad once, when its view is created (build your views here); viewWillAppear every time it’s about to be shown; viewDidDisappear when it’s gone. You override them, call super, and add your code.

Layout in code uses Auto Layout constraints: “this label’s leading edge is 16 points from its parent’s”, “the button is below the label”. Every view you lay out this way needs one line first: translatesAutoresizingMaskIntoConstraints = false.

The UIKit concepts that matter in practice: the view controller lifecycle (viewDidLoad once; viewWillAppear/viewIsAppearing (iOS 17, back-deployed to 13) for geometry and trait-dependent setup; viewDidLayoutSubviews can run many times), containment (child view controllers with addChild/didMove(toParent:)), Auto Layout (constraint activation, priorities, intrinsic content size, compression resistance and hugging), and collection views with UICollectionViewCompositionalLayout, UICollectionView.CellRegistration and UICollectionViewDiffableDataSource, which replace index-path bookkeeping with snapshots of identifiers. Snapshot identifiers must be unique and stable; reconfigure items (reconfigureItems, iOS 15) for content changes instead of reloadItems, which recreates the cell and causes flicker. Event handling is target-action (addAction(UIAction { … }, for: .primaryActionTriggered) with closures since iOS 14) and delegation, with weak delegate references.

final class OrderListViewController: UIViewController {
    enum Section { case main }

    private let orders: OrderStore                     // an @Observable model, shared with SwiftUI screens
    private var collectionView: UICollectionView!
    private var dataSource: UICollectionViewDiffableDataSource<Section, Order.ID>!
    var onOrderSelected: ((Order.ID) -> Void)?

    init(orders: OrderStore) {
        self.orders = orders
        super.init(nibName: nil, bundle: nil)
    }
    required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }

    override func viewDidLoad() {
        super.viewDidLoad()
        title = "Orders"

        let layout = UICollectionViewCompositionalLayout.list(using: .init(appearance: .insetGrouped))
        collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        collectionView.delegate = self
        view.addSubview(collectionView)
        NSLayoutConstraint.activate([
            collectionView.topAnchor.constraint(equalTo: view.topAnchor),
            collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
        ])

        let cell = UICollectionView.CellRegistration<UICollectionViewListCell, Order.ID> { [weak self] cell, _, id in
            guard let order = self?.orders.order(id) else { return }
            var content = cell.defaultContentConfiguration()
            content.text = order.title
            content.secondaryText = order.status.label
            cell.contentConfiguration = content
        }
        dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { cv, indexPath, id in
            cv.dequeueConfiguredReusableCell(using: cell, for: indexPath, item: id)
        }
        observeOrders()
    }

    // UIKit doesn't redraw on its own: observe the model and apply snapshots
    private func observeOrders() {
        withObservationTracking {
            applySnapshot(ids: orders.all.map(\.id), changed: orders.recentlyChanged)
        } onChange: { [weak self] in
            Task { @MainActor in self?.observeOrders() }
        }
    }

    private func applySnapshot(ids: [Order.ID], changed: Set<Order.ID>) {
        var snapshot = NSDiffableDataSourceSnapshot<Section, Order.ID>()
        snapshot.appendSections([.main])
        snapshot.appendItems(ids)
        snapshot.reconfigureItems(ids.filter(changed.contains))   // update content without recreating cells
        dataSource.apply(snapshot, animatingDifferences: true)
    }
}

extension OrderListViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        guard let id = dataSource.itemIdentifier(for: indexPath) else { return }
        onOrderSelected?(id)
    }
}
  • The data source holds ids, not orders. A snapshot says “these ids, in this order”; the diffable data source works out which rows were inserted, removed or moved, and animates it.
  • The cell registration fills a cell for an id by looking up the current order. When an order changes, reconfigureItems refills the existing cell with the new values, without the flicker of recreating it.
  • withObservationTracking lets UIKit code observe an @Observable model: when what was read changes, onChange runs once, and the code observes again.
  • Selection goes through the delegate and is reported with a closure, so the view controller doesn’t decide what screen comes next.
Spot the bugA header that doesn't show up

Tap the line with the bug.

final class OrderHeaderViewController: UIViewController {
    private let titleLabel = UILabel()
    private let totalLabel = UILabel()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.addSubview(titleLabel)
        view.addSubview(totalLabel)
        NSLayoutConstraint.activate([
            titleLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
            titleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
            totalLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8),
            totalLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
        ])
    }
}

Predict the output

A snapshot is built from orders.map(\.title) instead of ids, and two orders have the same title, 'Table 4'. What happens when it's applied?

  • Lifecycle: viewDidLoad once; viewIsAppearing/viewWillAppear for geometry and traits; viewDidLayoutSubviews repeatedly. Containment with addChild/didMove(toParent:).
  • Auto Layout in code: translatesAutoresizingMaskIntoConstraints = false; hugging/compression resistance; activate in batches.
  • Lists: compositional layout, cell registrations, diffable data sources with unique stable ids; reconfigureItems over reloadItems to avoid flicker.
  • UIKit observing @Observable via withObservationTracking (re-register in onChange); iOS 18 adds automatic observation tracking in layoutSubviews/viewWillLayoutSubviews (iOS 26 on by default).
  • Target-action with UIAction closures; weak delegates.

Check this part

  1. Which UIKit lifecycle method runs only once, when the view is created?

  2. What must you set on a view created in code before adding Auto Layout constraints?

  3. An order's status changes. How do you update its row without flicker?

A UIViewController owns a screen: build views in viewDidLoad, react to appearance in viewWillAppear, and update views yourself when data changes. Auto Layout in code needs translatesAutoresizingMaskIntoConstraints = false. Lists use UICollectionView with a diffable data source keyed by stable, unique identifiers.

Part Not started

Using each framework inside the other

UIKit inside SwiftUI: wrap the UIKit view in a struct that conforms to UIViewRepresentable (or UIViewControllerRepresentable for a whole view controller). Three parts:

  • makeUIView creates the UIKit view once.
  • updateUIView is called whenever the SwiftUI state it uses changes: copy the new values into the UIKit view.
  • A Coordinator is an object you create to receive the UIKit view’s delegate calls and pass them back to SwiftUI, usually through a Binding.

SwiftUI inside UIKit: wrap the SwiftUI view in a UIHostingController and add it like any other view controller. That’s how most older apps add new screens in SwiftUI.

In representables, makeUIView builds and configures once; updateUIView is the state-to-view sync and can run very often, so it must be idempotent and compare before setting (setting a text field’s text resets the cursor; setting a map’s region triggers delegate callbacks that write back to state, which triggers updateUIView: an update loop). The Coordinator holds a copy of the representable (refresh context.coordinator.parent = self in updateUIView so it sees current bindings), acts as delegate, and must not retain the UIKit view strongly in a way that creates cycles with closures. sizeThatFits(_:uiView:context:) (iOS 16) controls sizing. In the other direction, UIHostingController should be embedded with proper containment (addChild, add its view, didMove(toParent:)); sizingOptions = .intrinsicContentSize (iOS 16) lets Auto Layout follow the SwiftUI content’s size; UIHostingConfiguration (iOS 16) renders SwiftUI inside collection view cells without a hosting controller per cell.

// UIKit in SwiftUI: a text view with a binding
struct NotesTextView: UIViewRepresentable {
    @Binding var text: String

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

    func updateUIView(_ view: UITextView, context: Context) {
        context.coordinator.parent = self                 // keep the binding current
        if view.text != text { view.text = text }         // only when it differs: keeps the cursor
    }

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

    final class Coordinator: NSObject, UITextViewDelegate {
        var parent: NotesTextView
        init(parent: NotesTextView) { self.parent = parent }

        func textViewDidChange(_ textView: UITextView) {
            parent.text = textView.text                   // UIKit → SwiftUI
        }
    }
}

// SwiftUI in UIKit: a SwiftUI screen embedded as a child view controller
final class OrderDetailContainer: UIViewController {
    private let order: Order
    init(order: Order) { self.order = order; super.init(nibName: nil, bundle: nil) }
    required init?(coder: NSCoder) { fatalError() }

    override func viewDidLoad() {
        super.viewDidLoad()
        let hosting = UIHostingController(rootView: OrderDetailView(order: order))
        hosting.sizingOptions = .intrinsicContentSize
        addChild(hosting)
        hosting.view.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(hosting.view)
        NSLayoutConstraint.activate([
            hosting.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            hosting.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            hosting.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            hosting.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        ])
        hosting.didMove(toParent: self)
    }
}

// SwiftUI inside a collection view cell, without a hosting controller per cell
cell.contentConfiguration = UIHostingConfiguration {
    OrderRowView(order: order)
}

Production puzzles

Predict the output

A MapView representable sets uiView.setRegion(region, animated: true) unconditionally in updateUIView, and its Coordinator's regionDidChangeAnimated writes the map's region back to the @Binding. What happens when the user pans the map?

Predict the output

UIHostingController(rootView: OrderDetailView(order: order)) is created, and only hosting.view is added as a subview, without addChild/didMove. What can break?

Swift note

UIKit is @MainActor-isolated in the SDK, so your UIViewController subclasses are too. Calling into them from a background task requires await (or MainActor.run), and Swift 6 strict concurrency flags code that touches views from nonisolated contexts. Delegate protocols that UIKit calls on the main thread are annotated accordingly, so conformances in @MainActor classes need no extra work.

When to choose which

  • New screens: SwiftUI by default, hosted in UIKit navigation if the app is UIKit-based.
  • UIKit still wins for: very large or complex collection layouts with fine-grained control, advanced text editing (UITextView and TextKit 2), some camera and document scanning UIs, and anywhere you need precise control over view lifecycles and performance.
  • Migrating: screen by screen, at navigation boundaries (a UIHostingController per screen), not view by view inside one screen.
Exercisemedium

Wrap a UIKit control

You need a UISearchBar in a SwiftUI screen, with its text bound to a @State var query: String. Describe what goes into makeUIView, updateUIView and the Coordinator.

›

Follow the text both ways: from SwiftUI state into the search bar, and from typing back into the state.

SolutionTry it yourself first
  • makeUIView: create the UISearchBar, set its placeholder and style, and set searchBar.delegate = context.coordinator. Return it.
  • updateUIView: update context.coordinator.parent = self, then if searchBar.text != query { searchBar.text = query }, so typing isn’t interrupted.
  • Coordinator: a NSObject subclass conforming to UISearchBarDelegate, holding the representable (parent). In searchBar(_:textDidChange:) it sets parent.query = searchText; in searchBarSearchButtonClicked it can call searchBar.resignFirstResponder() to hide the keyboard.

(SwiftUI’s own .searchable modifier covers most cases; wrapping is for when you need behavior it doesn’t offer.)

  • Representables: build once in makeUIView; updateUIView idempotent and compare-before-set (cursor resets, delegate feedback loops); refresh coordinator.parent for current bindings; sizeThatFits for sizing.
  • Coordinators act as delegates and bridge UIKit events into bindings; avoid retain cycles.
  • UIHostingController with full containment (addChild → add view → didMove), sizingOptions = .intrinsicContentSize; UIHostingConfiguration for cells.
  • UIKit is @MainActor; migrate screen by screen at navigation boundaries.

Check this part

  1. In UIViewRepresentable, where do you create the UIKit view?

  2. Why compare before setting values in updateUIView?

  3. What's missing if a hosting controller's view is added without addChild?

UIViewRepresentable wraps a UIKit view for SwiftUI: create it once in makeUIView, push SwiftUI state into it in updateUIView (only when it differs, to avoid loops), and receive its delegate callbacks in a Coordinator. UIHostingController puts SwiftUI inside UIKit; add it as a child view controller, and size it with sizingOptions.

Key takeaways

  • A view controller owns a screen; you update its views yourself.
  • Views made in code need translatesAutoresizingMaskIntoConstraints = false for Auto Layout.
  • Collection views with diffable data sources work with unique, stable ids.
  • UIViewRepresentable brings UIKit into SwiftUI; UIHostingController does the opposite.
  • Lifecycle, containment, Auto Layout and diffable data sources are the core of real UIKit code.
  • reconfigureItems for content updates; never display values as identifiers.
  • Idempotent updateUIView, coordinators as delegates, no feedback loops.
  • Proper containment for hosting controllers; migrate at screen boundaries.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.