Highlight
UIKit on iOS 26 wires Swift Observation into update methods like
layoutSubviewsandupdateProperties. Read an@Observableproperty and UIKit records the dependency and invalidates the view for you. No more manualsetNeedsLayoutcalls.
Core content
UIKit veterans know the dance: change a model, call setNeedsLayout, then read the model again in layoutSubviews to refresh the view. For animations you also have to call layoutIfNeeded() inside the closure or constraint changes won’t animate. The whole dependency graph is hand-maintained. Miss one wire and you get stale or extra updates. UICollectionView cell configuration is a classic landmine.
UIKit on iOS 26 welds Swift Observation directly into the core update pipeline. Methods like layoutSubviews, updateProperties, and a cell’s configurationUpdateHandler will automatically track any @Observable properties they read, and UIKit invalidates the right view when those properties change. The new updateProperties method splits “content and style updates” from “layout math” — it runs before layoutSubviews and is independent of it, so unrelated events no longer trigger a full layout pass. Animations get a new flushUpdates option that flushes pending updates before the animation starts, so you can drop layoutIfNeeded(). Together these moves push UIKit closer to SwiftUI’s declarative model while keeping the existing view tree and performance profile.
Beyond that, iPadOS 26 brings the macOS menu bar to iPad — swipe down from the top to summon it. UISplitViewController now supports an inspector column natively. SwiftUI scenes can be embedded into a UIKit app via UIHostingSceneDelegate. UIColor supports HDR. The notification system gets strongly-typed NotificationCenter.Message. SF Symbols 7 adds draw animations. And one hard requirement: apps built against the latest SDK on versions after iOS 26 must migrate to the UIScene life cycle to launch at all.
Details
Automatic observation tracking: @Observable is enough
UIKit now tracks the @Observable properties you read inside methods like layoutSubviews and updateProperties (10:54).
// Using an Observable object and automatic observation tracking
@Observable class UnreadMessagesModel {
var showStatus: Bool
var statusText: String
}
class MessageListViewController: UIViewController {
var unreadMessagesModel: UnreadMessagesModel
var statusLabel: UILabel
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
statusLabel.alpha = unreadMessagesModel.showStatus ? 1.0 : 0.0
statusLabel.text = unreadMessagesModel.statusText
}
}
Key points:
UnreadMessagesModelis marked@Observable, so both properties become observable.viewWillLayoutSubviewsreadsshowStatusandstatusText; UIKit records the dependency.- When either property changes, UIKit invalidates the view and runs
viewWillLayoutSubviewsagain. - No KVO, no Combine, no hand-rolled
setNeedsLayout. - You can back-deploy to iOS 18 by adding
UIObservationTrackingEnabledtoInfo.plist.
updateProperties: separate content updates from layout
The new updateProperties method runs before layoutSubviews and is meant for configuring content, style, and behavior (13:27).
// Using automatic observation tracking and updateProperties()
@Observable class BadgeModel {
var badgeCount: Int?
}
class MyViewController: UIViewController {
var model: BadgeModel
let folderButton: UIBarButtonItem
override func updateProperties() {
super.updateProperties()
if let badgeCount = model.badgeCount {
folderButton.badge = .count(badgeCount)
} else {
folderButton.badge = nil
}
}
}
Key points:
updatePropertiesruns after trait updates and beforelayoutSubviews.- This example only configures the badge content. When
badgeCountchanges, only this block reruns — there’s no full layout pass. - You can read the trait collection (already updated) and you can invalidate layout from inside; UIKit will run layout right after.
- Trigger it manually with
setNeedsUpdateProperties. - It complements
layoutSubviewsrather than replacing it: content and style go here, geometry goes inlayoutSubviews.
flushUpdates: animations flush updates for you
UIView animations on iOS 26 get a .flushUpdates option that applies pending invalidations before and after the animation (16:57).
// Using the flushUpdates animation option to automatically animate updates
// Automatically animate changes with Observable objects
UIView.animate(options: .flushUpdates) {
model.badgeColor = .red
}
Key points:
- The closure only mutates
@Observableproperties — nolayoutIfNeeded(). - UIKit flushes pending updates once before the animation starts and once when it ends.
- Views that depend on the property are pulled into the animation automatically.
- The same trick works for Auto Layout: change a
constantorisActiveinside the closure and dependent views animate to their new positions.
Main menu system configuration
iPadOS 26 introduces the menu bar. Alongside UIMenuBuilder, the new UIMainMenuSystem.Configuration lets you trim default commands declaratively (4:56).
// Main menu system configuration
var config = UIMainMenuSystem.Configuration()
// Declare support for default commands, like printing
config.printingPreference = .included
// Opt out of default commands, like inspector
config.inspectorPreference = .removed
// Configure the Find commands to be a single "Search" element
config.findingConfiguration.style = .search
Key points:
printingPreference = .includedopts the app into the system print command set.inspectorPreference = .removedturns off the new inspector toggle command.findingConfiguration.style = .searchcollapses the Find command family into a single Search — better for image and music apps than Find Next/Previous.- Call this once in
application(_:didFinishLaunchingWithOptions:). Setting the configuration triggers a menu rebuild. - Heads-up: starting in iOS 26, menus from storyboards no longer load — you must build them in code.
Focus-based deferred menu element
Menu content like History depends on the current focus (which browsing profile, for example) and can’t be baked into the main menu build (8:06).
// Focus-based deferred menu elements
class BrowserViewController: UIViewController {
// ...
override func provider(
for deferredElement: UIDeferredMenuElement
) -> UIDeferredMenuElement.Provider? {
if deferredElement.identifier == .browserHistory {
return UIDeferredMenuElement.Provider { completion in
let browserHistoryMenuElements = profile.browserHistoryElements()
completion(browserHistoryMenuElements)
}
}
return nil
}
}
Key points:
- In the App Delegate, create a placeholder with
UIDeferredMenuElement.usingFocus(identifier:shouldCacheItems:)and insert it into the main menu. - The view controller overrides
provider(for:)and returns aUIDeferredMenuElement.Providerfor the matching identifier. UIKit walks up the responder chain until it finds one that can supply content. - The win: menus fill on demand, so a profile switch doesn’t rebuild the entire main menu.
Embedding SwiftUI scenes in a UIKit app
UIHostingSceneDelegate lets a UIKit app host SwiftUI’s WindowGroup and visionOS’s ImmersiveSpace (18:07).
// Setting up a UIHostingSceneDelegate
import UIKit
import SwiftUI
class ZenGardenSceneDelegate: UIResponder, UIHostingSceneDelegate {
static var rootScene: some Scene {
WindowGroup(id: "zengarden") {
ZenGardenView()
}
#if os(visionOS)
ImmersiveSpace(id: "zengardenspace") {
ZenGardenSpace()
}
.immersionStyle(selection: .constant(.full),
in: .mixed, .progressive, .full)
#endif
}
}
Key points:
rootSceneis a SwiftUISceneand can hold multiple WindowGroups or ImmersiveSpaces.- Set
delegateClassto this class insideapplication(_:configurationForConnecting:options:)to wire it up. - Use
UISceneSessionActivationRequest(hostingDelegateClass:id:)to request a specific scene programmatically — for example, to open a visionOS immersive space. - Good fit for UIKit apps adopting SwiftUI step by step, and for any app that needs visionOS immersive spaces.
Strongly-typed NotificationCenter.Message
NotificationCenter.default.addObserver(of:for:) now returns a strongly-typed message — no more userInfo dictionaries (20:54).
// Adopting Swift notifications
override func viewDidLoad() {
super.viewDidLoad()
let keyboardObserver = NotificationCenter.default.addObserver(
of: UIScreen.self
for: .keyboardWillShow
) { message in
UIView.animate(
withDuration: message.animationDuration, delay: 0, options: .flushUpdates
) {
// Use message.endFrame to animate the layout of views with the keyboard
let keyboardOverlap = view.bounds.maxY - message.endFrame.minY
bottomConstraint.constant = keyboardOverlap
}
}
}
Key points:
for: .keyboardWillShowis a strongly-typedNotificationCenter.Messagename.message.animationDurationandmessage.endFramegive you the keyboard animation duration and end frame directly — nouserInfo[UIResponder.keyboardFrameEndUserInfoKey]lookup-and-cast.- Combine it with
.flushUpdatesand your constraint changes ride the keyboard animation.
Takeaways
1. Refactor cell configuration onto configurationUpdateHandler + @Observable
Why it pays off: cell configuration is usually scattered across cellForItemAt, prepareForReuse, and KVO callbacks, and keeping state in sync is easy to break. configurationUpdateHandler now supports automatic observation tracking, so you define it once and it updates itself (11:48).
How to start: turn the cell’s model into an @Observable class. After dequeue in cellForItemAt, assign configurationUpdateHandler. Inside the handler, read the model’s properties to build a UIListContentConfiguration. Property changes will reconfigure the cell automatically — no setNeedsConfigurationUpdate needed.
2. Move “content and style” code from layoutSubviews to updateProperties
Why it pays off: layoutSubviews runs on every resize, scroll, and subview change. Mixing badge counts, text, and colors into it does a lot of pointless work. The new updateProperties runs only when content invalidates, so the work is cheaper and the responsibilities are cleaner.
How to start: walk through your existing viewWillLayoutSubviews and layoutSubviews code. Move every “read model, set property” line into override func updateProperties(). Leave layout math (frames, constraint constants) where it is. Call setNeedsUpdateProperties() when you need to trigger it by hand.
3. Start the UIScene migration now
Why it pays off: apps built against the latest SDK on versions after iOS 26 will not launch unless they have moved to the UIScene life cycle. Many older UIApplicationDelegate callbacks and UIWindow initializers are already deprecated (21:19).
How to start: read Apple’s tech note Migrating to the UIKit scene-based life cycle. Begin by adding UIApplicationSceneManifest to Info.plist. Even single-window apps need to migrate. Move UI setup out of application(_:didFinishLaunchingWithOptions:) and into UISceneDelegate.scene(_:willConnectTo:options:). Switch UIWindow to init(windowScene:).
4. Move keyboard and notification code to strongly-typed NotificationCenter.Message
Why it pays off: the old keyboardWillShowNotification plus userInfo dictionary lookups read poorly, the keys are easy to misspell, and you have to cast values out by hand. The strongly-typed message hands you animationDuration, endFrame, and friends as real properties, checked at compile time.
How to start: replace addObserver(forName:object:queue:using:) with addObserver(of:for:). Change the callback to (message) in and read message.xxx directly. Add .flushUpdates to the animation closure and your constraint changes ride the keyboard animation.
Related sessions
- Build a UIKit app with the new design — hands-on guide to upgrading an existing UIKit app to the Liquid Glass design
- Make your UIKit app more flexible — container view controllers, layout margins, migrating off UIRequiresFullScreen
- Elevate the design of your iPad app — design-level guidance for the iPadOS menu bar and the new design
- What’s new in SF Symbols 7 — a deep dive on draw animations, variable draw, and the gradient rendering mode
- Take your iPad apps to the next level — a tour of the main menu and multitasking
Comments
GitHub Issues · utterances