Write, review, or improve UIKit code following best practices for view controller lifecycle, Auto Layout, collection views, navigation, animation, memory management, and modern iOS 18–26 APIs. Use when building new UIKit features, refactoring existing views or view controllers, reviewing code quality, adopting modern UIKit patterns (diffable data sources, compositional layout, cell configuration), or bridging UIKit with SwiftUI. Does not cover SwiftUI-only code.
96
100%
Does it follow best practices?
Impact
96%
1.23xAverage score across 9 eval scenarios
Passed
No known issues
UIKit's 2025–2026 releases represent its most significant evolution since diffable data sources. iOS 26 makes UIKit reactive by default — the Observation framework now drives automatic view updates, a new updateProperties() lifecycle method cleanly separates content from layout, and .flushUpdates eliminates manual layoutIfNeeded() calls during animation. Meanwhile, the scene lifecycle becomes mandatory, Liquid Glass reshapes the interaction layer, and typed notifications bring compile-time safety to NotificationCenter. This guide covers every major API change with production-ready Swift code.
Apple quietly shipped automatic observation tracking in iOS 18 as an opt-in feature, then made it the default in iOS 26. When enabled, UIKit wraps key lifecycle methods in Swift Observation tracking contexts. Any @Observable property read during those methods registers a dependency — and when that property changes, UIKit automatically invalidates the relevant method.
For apps targeting iOS 18 through iOS 25, add this to your Info.plist:
<key>UIObservationTrackingEnabled</key>
<true/>On macOS 15+, the equivalent key is NSObservationTrackingEnabled. On iOS 26, the key is ignored — tracking is always on.
UIKit wraps these methods with observation tracking, organized by class:
updateProperties() (iOS 26+), layoutSubviews(), updateConstraints(), draw(_:)updateProperties() (iOS 26+), viewWillLayoutSubviews(), viewDidLayoutSubviews(), updateViewConstraints(), updateContentUnavailableConfiguration(using:)updateConfiguration(using:), configurationUpdateHandlerupdateConfiguration(), configurationUpdateHandlercontainerViewWillLayoutSubviews(), containerViewDidLayoutSubviews()Reading an @Observable property inside any tracked method registers that property as a dependency. UIKit then calls the appropriate invalidation method (setNeedsLayout(), setNeedsUpdateProperties(), setNeedsDisplay()) when the property changes. The dependency set is dynamic — only properties actually read during a given execution are tracked:
@Observable
class Counter {
var count: Int = 0
}
class CounterViewController: UIViewController {
let counter = Counter()
private let label = UILabel()
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(label)
// setup constraints, tap gesture, etc.
}
// On iOS 18 (with plist key): use viewWillLayoutSubviews
// On iOS 26: use updateProperties (preferred)
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
label.text = "Count: \(counter.count)" // Dependency recorded
}
@objc func increment() {
counter.count += 1 // Triggers re-run of viewWillLayoutSubviews
}
}Collection view cells benefit enormously — a single configurationUpdateHandler replaces manual snapshot updates:
@Observable class ListItemModel {
var icon: UIImage?
var title: String
var subtitle: String
}
// In cell provider:
cell.configurationUpdateHandler = { cell, state in
var content = UIListContentConfiguration.subtitleCell()
content.image = listItemModel.icon // Tracked
content.text = listItemModel.title // Tracked
content.secondaryText = listItemModel.subtitle // Tracked
cell.contentConfiguration = content
}
// Change any property while visible → handler re-runs automaticallyiOS 26 introduces updateProperties() on both UIView and UIViewController, providing a dedicated phase for content and styling that runs before layout but after trait collection updates. The update cycle now proceeds: traits → updateProperties() → layoutSubviews() → draw(_:).
This separation matters because invalidating properties no longer forces a layout pass, and vice versa:
@Observable
class BadgeModel {
var count: Int = 0
var backgroundColor: UIColor = .systemBlue
}
class BadgeView: UIView {
let model: BadgeModel
let label = UILabel()
// Content and styling — runs independently of layout
override func updateProperties() {
super.updateProperties()
label.text = "\(model.count)"
backgroundColor = model.backgroundColor
}
// Geometry only
override func layoutSubviews() {
super.layoutSubviews()
label.frame = bounds.insetBy(dx: 8, dy: 4)
}
}The full API surface includes setNeedsUpdateProperties() for manual invalidation and updatePropertiesIfNeeded() for forcing immediate evaluation.
An update storm occurs when a tracked method writes to an observable property it also reads, creating an infinite invalidation loop. Follow these rules:
Rule 1 — Never write to observed properties inside tracked methods:
// ❌ Writing to model inside tracked method → infinite loop
override func updateProperties() {
super.updateProperties()
model.formattedCount = "Count: \(model.count)" // WRITES to model
label.text = model.formattedCount
}
// ✅ Only READ observable properties; write to view properties
override func updateProperties() {
super.updateProperties()
label.text = "Count: \(model.count)" // Reads model, writes to label
}Rule 2 — Don't create cross-dependencies between updateProperties and layoutSubviews:
// ❌ layoutSubviews writes to model → triggers updateProperties → invalidates layout
override func layoutSubviews() {
super.layoutSubviews()
model.currentWidth = bounds.width // Writing to @Observable → loop!
}
// ✅ Use non-observable state for layout feedback
private var cachedWidth: CGFloat = 0 // Plain property, not @Observable
override func layoutSubviews() {
super.layoutSubviews()
cachedWidth = bounds.width
}Rule 3 — Properties with layout-invalidating side effects belong in updateProperties(), not layoutSubviews(). Apple's documentation states explicitly: "Some properties aren't appropriate to change during layoutSubviews(), for example, properties where setting the value has a side-effect of invalidating the view's layout."
Before iOS 26, animating constraint or observable-driven changes required a manual three-step layoutIfNeeded() dance. .flushUpdates (iOS 26+) eliminates this by automatically flushing pending trait, property, and layout updates before and after the animation closure:
// ❌ iOS 18 and earlier — manual approach
view.layoutIfNeeded()
heightConstraint.constant = 100
UIView.animate(withDuration: 0.3) {
self.view.layoutIfNeeded()
}
// ✅ iOS 26 — automatic approach
UIView.animate(withDuration: 0.3, options: .flushUpdates) {
heightConstraint.constant = 100
}The critical rule: only make invalidating state changes inside the animation closure — just set the new values and let UIKit's automatic tracking handle the rest.
For full constraint animation patterns and the .flushUpdates API (including UIViewPropertyAnimator, @Observable integration, and keyboard handling), see references/auto-layout.md § "Constraint animation and iOS 26's .flushUpdates" and references/animation-patterns.md § "Constraint animation and the iOS 26 revolution".
Apple has been tightening the screws on scene lifecycle adoption across three releases. In iOS 18.4, UIKit began logging a warning: "This process does not adopt UIScene lifecycle." In iOS 26, that warning escalates but the app still launches. In iOS 27 (building with the iOS 27 SDK), apps that haven't adopted the scene lifecycle will crash on launch with an assert before any AppDelegate methods fire.
The enforcement is tied to the SDK you build with, not the OS version the app runs on. Existing binaries compiled with older SDKs continue to work. Only adoption of the scene lifecycle is required — supporting multiple scenes is optional.
Add UIApplicationSceneManifest to your Info.plist:
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>Default Configuration</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
</dict>
</array>
</dict>
</dict>The UIWindow(frame:) initializer is deprecated in iOS 26. All window creation must use UIWindow(windowScene:):
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene else { return }
window = UIWindow(windowScene: windowScene)
window?.rootViewController = MyRootViewController()
window?.makeKeyAndVisible()
// State restoration: check for previous activity
if let activity = session.stateRestorationActivity {
(window?.rootViewController as? MyRootViewController)?
.restoreState(from: activity)
}
}
func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {
return scene.userActivity
}
}Critical migration detail: after adopting UIScene lifecycle, launchOptions in didFinishLaunchingWithOptions: will be nil. Any launch-options logic must move to scene(_:willConnectTo:options:) via connectionOptions.
The AppDelegate retains application(_:didFinishLaunchingWithOptions:) for non-UI setup, APNs callbacks, application(_:configurationForConnecting:options:), and application(_:didDiscardSceneSessions:). Everything else — the four lifecycle methods (didBecomeActive, willResignActive, didEnterBackground, willEnterForeground), URL handling, and user activity handling — moves to UISceneDelegate equivalents like sceneDidBecomeActive(_:), scene(_:openURLContexts:), etc.
Apple Technical Note TN3187 (published May 2025, revised June 2025) outlines the migration process: determine if migration is needed (missing UIApplicationSceneManifest or unimplemented configurationForConnecting), choose static (Info.plist) or dynamic (AppDelegate callback) configuration, create a SceneDelegate class, migrate lifecycle methods, and test in Split View and Stage Manager on iPad.
Liquid Glass is iOS 26's defining design element — a translucent, specular material that floats above content for interactive elements. Standard UIKit controls (tab bars, navigation bars, alerts) adopt it automatically when built with Xcode 26. Custom glass effects use UIGlassEffect with UIVisualEffectView.
if #available(iOS 26, *) {
let glassEffect = UIGlassEffect()
glassEffect.isInteractive = true // Scales and bounces on tap
glassEffect.tintColor = .systemBlue
let effectView = UIVisualEffectView(effect: glassEffect)
effectView.frame = CGRect(x: 50, y: 100, width: 300, height: 200)
let label = UILabel(frame: effectView.bounds)
label.text = "Glass Surface"
label.textAlignment = .center
effectView.contentView.addSubview(label)
view.addSubview(effectView)
} else {
// Fallback for older iOS
let blurEffect = UIBlurEffect(style: .systemMaterial)
effectView.effect = blurEffect
}For grouping multiple glass elements that merge when proximate, use UIGlassContainerEffect:
if #available(iOS 26, *) {
let containerEffect = UIGlassContainerEffect()
containerEffect.spacing = 40.0 // Merge distance threshold
let containerView = UIVisualEffectView(effect: containerEffect)
let firstGlass = UIVisualEffectView(effect: UIGlassEffect())
let secondGlass = UIVisualEffectView(effect: UIGlassEffect())
containerView.contentView.addSubview(firstGlass)
containerView.contentView.addSubview(secondGlass)
view.addSubview(containerView)
}Key implementation guidance: use glass sparingly and only for interactive elements, remove custom backgrounds on sheets to let glass texture show through, prefer setting effect = nil over changing alpha for removal (triggers proper dematerialization animation), and note that larger glass surfaces render more opaque while smaller ones are clearer.
iOS 26's tab bar floats over content with Liquid Glass, can minimize on scroll, and automatically adapts between compact tabs (iPhone) and sidebar (iPad) when you use the UITab API with UITabGroup:
let tabBarController = UITabBarController()
tabBarController.mode = .tabSidebar
tabBarController.tabs = [
UITab(title: "Home", image: UIImage(systemName: "house"),
identifier: "Home") { _ in HomeViewController() },
UITabGroup(
title: "Library",
image: UIImage(systemName: "books.vertical"),
identifier: "Library",
children: [
UITab(title: "Albums", image: UIImage(systemName: "photo"),
identifier: "Albums") { _ in AlbumsViewController() },
UITab(title: "Artists", image: UIImage(systemName: "music.mic"),
identifier: "Artists") { _ in ArtistsViewController() },
]
) { _ in LibraryViewController() },
UISearchTab { _ in
UINavigationController(rootViewController: SearchViewController())
}
]
// Enable minimize-on-scroll
tabBarController.tabBarMinimizeBehavior = .onScrollDownTab groups appear as a single tab in the compact tab bar and expand into sections in the sidebar. Individual tabs support preferredPlacement options: .fixed (always visible), .sidebarOnly, and .pinned (trailing edge, icon only). The sidebar shows automatically on iPad landscape and can be toggled via tabBarController.sidebar.isHidden.
iOS 26 introduces NotificationCenter.MainActorMessage and NotificationCenter.AsyncMessage protocols that replace untyped Notification objects with strongly-typed, concurrency-safe message structs. No more userInfo dictionary casting at runtime.
public struct DownloadDidFinish: NotificationCenter.MainActorMessage {
public typealias Subject = DownloadManager
// Strongly typed payload — replaces userInfo
public let fileURL: URL
public let success: Bool
}
// Post
NotificationCenter.default.post(
DownloadDidFinish(fileURL: url, success: true),
subject: DownloadManager.shared
)let token = NotificationCenter.default.addObserver(
of: DownloadManager.shared,
for: DownloadDidFinish.self
) { message in
// Closure is main-actor isolated — guaranteed main thread
print("File: \(message.fileURL), success: \(message.success)")
}MainActorMessage guarantees main-thread execution; AsyncMessage is Sendable for cross-concurrency use. Both protocols bridge with legacy Notification by implementing makeMessage(_:) and makeNotification(_:object:), so old-style posts are received by new-style observers and vice versa. Apple already ships typed messages for system notifications — for example, UIScreen.keyboardWillShow replaces UIResponder.keyboardWillShowNotification.
The old traitCollectionDidChange fires for every trait change. The new API registers for specific traits only:
// ❌ Deprecated
override func traitCollectionDidChange(_ previous: UITraitCollection?) {
super.traitCollectionDidChange(previous)
if previous?.horizontalSizeClass != traitCollection.horizontalSizeClass {
configureLayout()
}
}
// ✅ Modern (iOS 17+)
override func viewDidLoad() {
super.viewDidLoad()
registerForTraitChanges([UITraitHorizontalSizeClass.self]) {
(self: Self, _: UITraitCollection) in
self.configureLayout()
}
}One constraint replaces 30+ lines of notification handling code:
// ❌ Deprecated: manual notification + frame math + layoutIfNeeded
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow),
name: UIResponder.keyboardWillShowNotification, object: nil)
// ✅ Modern (iOS 15+)
view.keyboardLayoutGuide.topAnchor.constraint(
equalToSystemSpacingBelow: textField.bottomAnchor,
multiplier: 1.0
).isActive = true// ❌ Deprecated
cell.textLabel?.text = "Title"
cell.detailTextLabel?.text = "Subtitle"
// ✅ Modern (iOS 14+)
var content = cell.defaultContentConfiguration()
content.text = "Title"
content.secondaryText = "Subtitle"
content.textProperties.font = .preferredFont(forTextStyle: .headline)
cell.contentConfiguration = content// ❌ Deprecated — breaks in iOS 15 (transparent scrollEdge default)
navigationBar.barTintColor = .systemRed
navigationBar.titleTextAttributes = [.foregroundColor: UIColor.white]
// ✅ Modern (iOS 13+)
let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = .systemRed
appearance.titleTextAttributes = [.foregroundColor: UIColor.white]
navigationBar.standardAppearance = appearance
navigationBar.scrollEdgeAppearance = appearance // Critical: prevents transparent edgereconfigureItems reuses the existing cell and re-runs the configuration handler. reloadItems dequeues a completely new cell. Reconfigure is dramatically faster for data-only changes:
// ❌ Old: creates a new cell
snapshot.reloadItems([itemID])
// ✅ Modern (iOS 15+): reuses existing cell, re-runs config handler
snapshot.reconfigureItems([itemID])Use reloadItems only when you need to change the cell type or require prepareForReuse() to fire.
// ❌ Deprecated: string identifiers, force casting, separate register step
collectionView.register(MyCell.self, forCellWithReuseIdentifier: "MyCell")
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "MyCell",
for: indexPath) as! MyCell
// ✅ Modern (iOS 14+): type-safe, no strings, auto-registered
let registration = UICollectionView.CellRegistration<UICollectionViewListCell, Item> {
cell, indexPath, item in
var content = cell.defaultContentConfiguration()
content.text = item.name
cell.contentConfiguration = content
}
// In data source provider:
collectionView.dequeueConfiguredReusableCell(using: registration, for: indexPath, item: item)// ❌ Old: requires delegate methods for sizing, limited to simple grids
let layout = UICollectionViewFlowLayout()
// + UICollectionViewDelegateFlowLayout for sizeForItemAt...
// ✅ Modern (iOS 14+ for lists): two lines for a full table-view replacement
var config = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
config.headerMode = .supplementary
let layout = UICollectionViewCompositionalLayout.list(using: config)CompositionalLayout supports per-section layouts via sectionProvider, orthogonal scrolling with section.orthogonalScrollingBehavior = .continuous, and declarative sizing with .fractionalWidth, .absolute, and .estimated dimensions — all without subclassing or delegate methods.
The iOS 18–26 era marks UIKit's transition from an imperative framework to a reactive, declarative-friendly system. Three changes stand out as the most impactful for existing codebases.
First, automatic observation tracking (available today on iOS 18 with a single plist key) eliminates manual invalidation patterns that have plagued UIKit development for over a decade. The new updateProperties() phase in iOS 26 makes the separation of content updates from layout geometry both natural and enforced by the framework.
Second, the mandatory scene lifecycle in iOS 27 SDK builds means migration is no longer optional. Apps that haven't adopted UISceneDelegate by the iOS 27 timeframe will crash on launch. TN3187 provides a clear migration path, but the work should start now.
textLabel, string-based cell registration, or FlowLayout are leaving significant performance and maintainability gains on the table. The modern APIs aren't just newer — they're fundamentally better abstractions that produce less code, fewer bugs, and faster cells.UIObservationTrackingEnabled added to Info.plist for iOS 18+ targets@Observable properties read inside tracked methods (layoutSubviews, updateProperties) register dependencies automaticallyupdateProperties() used for content/styling; layoutSubviews() reserved for geometry.flushUpdates used for constraint and Observable-driven animationUIApplicationSceneManifest in Info.plist, window setup in SceneDelegatescene(_:willConnectTo:options:) or scene(_:continue:)UIGlassEffect with UIVisualEffectView, gated behind #available(iOS 26, *)UITabBarAppearance removed behind #available check#available and provide sensible fallbacks