View controllers, layout and lists
Here’s how SwiftUI ideas map to UIKit:
| SwiftUI | UIKit |
|---|---|
A View struct for a screen | A UIViewController subclass |
Text, Button, Image | UILabel, UIButton, UIImageView |
VStack, HStack | UIStackView, or Auto Layout constraints |
List | UICollectionView (or the older UITableView) |
NavigationStack | UINavigationController |
.onAppear | viewWillAppear / viewDidAppear |
| State changes redraw the view | You 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,
reconfigureItemsrefills the existing cell with the new values, without the flicker of recreating it. withObservationTrackinglets UIKit code observe an@Observablemodel: when what was read changes,onChangeruns 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.
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),
])
}
}Hint: The console prints 'Unable to simultaneously satisfy constraints' and mentions NSAutoresizingMaskLayoutConstraint.
Views created in code have translatesAutoresizingMaskIntoConstraints = true by default: UIKit turns their frame (here zero size at the origin) into constraints automatically. Those generated constraints conflict with the ones you added, UIKit breaks some of them at random, and the labels end up at the top-left corner with zero size, or partly visible.
Turn it off for every view you position with Auto Layout:
override func viewDidLoad() {
super.viewDidLoad()
for label in [titleLabel, totalLabel] {
label.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(label)
}
NSLayoutConstraint.activate([ /* same constraints */ ])
}Views from storyboards and UIStackView’s arranged subviews are handled for you; views you create in code are not. When the console mentions NSAutoresizingMaskLayoutConstraint, this is almost always the cause.
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?
Diffable data sources identify rows by their item identifiers, so identifiers must be unique (and stable over time). Duplicates trigger an exception about non-unique identifiers (or, in some versions, a crash in debug and undefined behavior in release). Use the model's stable id, never a display value, and never a new UUID() per snapshot, which would make every row look new and animate everything.
- Lifecycle:
viewDidLoadonce;viewIsAppearing/viewWillAppearfor geometry and traits;viewDidLayoutSubviewsrepeatedly. Containment withaddChild/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;
reconfigureItemsoverreloadItemsto avoid flicker. - UIKit observing
@ObservableviawithObservationTracking(re-register inonChange); iOS 18 adds automatic observation tracking inlayoutSubviews/viewWillLayoutSubviews(iOS 26 on by default). - Target-action with
UIActionclosures; weak delegates.
Check this part
Which UIKit lifecycle method runs only once, when the view is created?
What must you set on a view created in code before adding Auto Layout constraints?
An order's status changes. How do you update its row without flicker?