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 = falseon 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
UIStackViewfor rows and columns, as you wouldHStackandVStack. - 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 ?? "" }
}
}
UIViewRepresentablehasmakeUIView(create once) andupdateUIView(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.UIViewControllerRepresentableis the same idea for a whole view controller, such as a camera picker.UIHostingControlleris 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
cellForRowAtor in the diffable data source closure, which makes scrolling jerky. - Mixing manual
framevalues with Auto Layout on the same view.
Your task
- Make a view controller with a label and a button laid out with constraints, and check it in a small and a large iPhone.
- Build the diffable table view and add a button that appends a new item with animation.
- Wrap
UITextFieldinUIViewRepresentableand use it in a SwiftUI form. - Show a SwiftUI view inside a
UIHostingControllerpushed 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.