UIKit for SwiftUI Developers: Interop, Auto Layout and Table Views

Advanced iOS Development: Concurrency, Architecture, Security and Release · Advanced UI and Navigation · 8 min read · Updated

UIKit and SwiftUI together

  1. An older UIKit app is made of view controllers.
  2. A UIHostingController lets a UIKit screen show a SwiftUI view, so new screens can use SwiftUI.
  3. The SwiftUI view runs inside it as normal.
  4. The other way round, UIViewRepresentable wraps a UIKit view for use in SwiftUI.

Many companies started their apps before SwiftUI existed, and a rewrite is rarely worth the risk. If you join such a team, most of your day is in UIKit. Even a brand new SwiftUI app will use UIKit through the camera, the map or a text view that SwiftUI does not cover well. This lesson gives you enough UIKit to read a code base, fix a bug and add a screen. It does not try to teach all of it.

The mental model

  • A screen is a view controller (UIViewController). It owns a view, which owns smaller views.
  • UIKit is imperative: you create views, set properties, and change them yourself when data changes. SwiftUI describes the result, and UIKit gives the steps.
  • UIKit is built on classes, so the memory rules of lesson 3 apply. Delegates must be weak, and closures stored on the controller need [weak self].

The view controller life cycle

import UIKit

final class ExpenseDetailViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        // the view is loaded: create subviews, set constraints (runs once)
    }
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        // about to show: refresh data (runs every time)
    }
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        // visible: start animations
    }
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        // about to leave: save, stop work
    }
}

The order matters in interviews and in bugs. Set things up once in viewDidLoad. Refresh in viewWillAppear, which runs again when the user comes back with the back button. Do not measure sizes in viewDidLoad, because the layout is not final there.

Auto Layout in code

Auto Layout places views with *constraints*, rules such as *this label's top is 16 points below the safe area* and *this button is centred*. You can set them in Interface Builder (a storyboard or a .xib file), or in code. Code is easier to review in Git and to merge between people, and many teams prefer it.

final class SummaryViewController: UIViewController {
    private let titleLabel = UILabel()
    private let actionButton = UIButton(type: .system)

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

        titleLabel.text = "This month"
        titleLabel.font = .preferredFont(forTextStyle: .title2)
        actionButton.setTitle("Add expense", for: .normal)

        [titleLabel, actionButton].forEach {
            $0.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview($0)
        }

        NSLayoutConstraint.activate([
            titleLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 24),
            titleLabel.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
            actionButton.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 16),
            actionButton.centerXAnchor.constraint(equalTo: view.centerXAnchor)
        ])
    }
}
  • Always set translatesAutoresizingMaskIntoConstraints = false on views you constrain yourself. Forgetting it is the most common Auto Layout bug.
  • Give every view enough constraints to know its position and size. If the console prints *Unable to simultaneously satisfy constraints*, two rules disagree, and the message names them.
  • Use UIStackView for rows and columns, as you would HStack and VStack.
  • Use .preferredFont(forTextStyle:) so text follows the user's size setting.

A table view with a diffable data source

UITableView is the ancestor of List. The modern way to feed it is a diffable data source. You give it a *snapshot*, a description of what should be shown, and it works out what to insert, delete and move, with animation. This replaces the old index-path bookkeeping that caused many crashes.

struct ExpenseItem: Hashable {
    let id = UUID()
    let title: String
}

final class ExpenseListViewController: UITableViewController {
    private lazy var dataSource = UITableViewDiffableDataSource<Int, ExpenseItem>(tableView: tableView) {
        tableView, indexPath, item in
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        var content = cell.defaultContentConfiguration()
        content.text = item.title
        cell.contentConfiguration = content
        return cell
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        title = "Expenses"
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
        apply([ExpenseItem(title: "Coffee"), ExpenseItem(title: "Train")])
    }

    func apply(_ items: [ExpenseItem]) {
        var snapshot = NSDiffableDataSourceSnapshot<Int, ExpenseItem>()
        snapshot.appendSections([0])
        snapshot.appendItems(items)
        dataSource.apply(snapshot, animatingDifferences: true)
    }
}

dequeueReusableCell is the key idea of table views. The table does not create a cell for every row. It reuses the few cells that are on screen, which keeps long lists fast. This is also why a cell must be fully set up each time it is configured. A leftover value from the previous row is a classic bug.

SwiftUI inside UIKit, and UIKit inside SwiftUI

Both directions are supported, so you can move an app screen by screen.

// SwiftUI inside UIKit
let host = UIHostingController(rootView: ExpenseChart())
navigationController?.pushViewController(host, animated: true)

// UIKit inside SwiftUI: wrap a UITextField and report changes back
struct SearchField: UIViewRepresentable {
    @Binding var text: String

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

    func makeUIView(context: Context) -> UITextField {
        let field = UITextField()
        field.placeholder = "Search"
        field.borderStyle = .roundedRect
        field.addTarget(context.coordinator, action: #selector(Coordinator.changed(_:)), for: .editingChanged)
        return field
    }

    func updateUIView(_ field: UITextField, context: Context) {
        if field.text != text { field.text = text }
    }

    final class Coordinator: NSObject {
        var parent: SearchField
        init(_ parent: SearchField) { self.parent = parent }
        @objc func changed(_ sender: UITextField) { parent.text = sender.text ?? "" }
    }
}
  • UIViewRepresentable has makeUIView (create once) and updateUIView (called when SwiftUI state changes). The Coordinator is a helper class that receives UIKit's delegate and target-action calls and passes them back through the binding.
  • UIViewControllerRepresentable is the same idea for a whole view controller, such as a camera picker.
  • UIHostingController is a view controller that shows a SwiftUI view.

Core Animation and UIView animation

UIKit views are backed by layers from Core Animation. For simple changes, you rarely need to touch them. UIView.animate animates changes to properties such as alpha, frame and transform. When you need more, such as a pulsing effect, use a CABasicAnimation on the layer.

UIView.animate(withDuration: 0.3) {
    badgeView.alpha = 1
    badgeView.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
}

let pulse = CABasicAnimation(keyPath: "transform.scale")
pulse.fromValue = 1.0
pulse.toValue = 1.15
pulse.duration = 0.6
pulse.autoreverses = true
pulse.repeatCount = .infinity
badgeView.layer.add(pulse, forKey: "pulse")

Common mistakes

  • A delegate property that is not weak, which leaks the controller.
  • Changing the screen from a background thread. UIKit must be used on the main thread.
  • Heavy work in cellForRowAt or in the diffable data source closure, which makes scrolling jerky.
  • Mixing manual frame values with Auto Layout on the same view.

Your task

  1. Make a view controller with a label and a button laid out with constraints, and check it in a small and a large iPhone.
  2. Build the diffable table view and add a button that appends a new item with animation.
  3. Wrap UITextField in UIViewRepresentable and use it in a SwiftUI form.
  4. Show a SwiftUI view inside a UIHostingController pushed from a UIKit screen.

How to check it worked

  • The constraints give no warnings in the console on any iPhone size.
  • A new item appears in the table with an animation, and no manual index paths are used.
  • You can list the life cycle methods in the order they run, and say what belongs in each.

Frequently asked questions

Do I still need to learn UIKit in 2026?

Yes, if you want to work on existing apps. Many companies still have large UIKit code bases, and even SwiftUI apps use UIKit for features SwiftUI does not cover well.

What is a diffable data source?

A modern way to feed a table or collection view with a snapshot of what should be shown. It works out the inserts, deletes and moves for you, with animation, and avoids index-path bugs.

What is the Coordinator in UIViewRepresentable?

A helper class that receives UIKit delegate and target-action calls and passes the results back to SwiftUI, for example by updating a binding.