Built-in support for Swift Observable in AppKit and UIKit on macOS 26 and iOS 26

macOS 14 and iOS 17 saw the introduction of the Observation
framework and the Observable
macro, allowing your SwiftUI views to automatically update in response to underlying data changing. However, that initial implementation contained really limited capabilities for consuming Observable
types from outside SwiftUI.
Before macOS 26 and iOS 26
Previously, if I wanted to continuously drive updates to an AppKit or UIKit view in response to Observable
changing, I’d have to use withObservationTracking
, whose closure would be called on willSet
of the underlying value and wasn’t continuous. So, you could end up with hacky methods that kind of worked like:
extension Observation.Observable {
public func onChange<T>(
of value: @escaping @autoclosure () -> T,
execute: @escaping (T) -> Void
) {
withObservationTracking {
execute(value())
} onChange: {
self.onChange(of: value(), execute: execute)
}
}
}
You’d then do work on the next turn of the RunLoop
or spin up an unstructured Task
to get the new value from the updated property. Overall, it’s clear the original Observable
wasn’t meant for these use cases!
New in AppKit and UIKit
On macOS 26 and iOS 26, both AppKit and UIKit have built-in support for Observable
types, covering a lot of the use cases I’ve personally faced in a mixed UI framework codebase. The UIKit changes are covered in this WWDC video, while the corresponding AppKit changes weren’t explicitly mentioned. I’ll summarize both here and aim to provide some depth for the underlying implementation.
How it works
In update methods on NSViewController
and NSView
in AppKit, alongside UIViewController
and UIView
in UIKit, the framework now automatically tracks any Observable
you reference, wires up dependencies, and invalidates the right views. This works via a mechanism similar to withObservationTracking
, where any property you reference within the apply
closure would inform the caller of value changes made to participating properties by way of the onChange
closure, except, instead of an explicit apply
closure, you are tracking any Observable
property you’re referencing within your update methods. This eliminates the recursive call pattern from the hacky onChange
handler.
For an example of how this works, consider the following snippet from the UIKit WWDC talk, which will be similar in the AppKit world as well:
@Observable final class UnreadMessagesModel {
var showStatus: Bool
var statusText: String
}
final 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
}
}
Because I reference the Observable
properties in my viewWillLayoutSubviews
implementation, they are automatically tracked. Any further change to those referenced properties invalidates the view and reruns viewWillLayoutSubviews
, keeping the label in sync with the model without extra code or manual didSet
hooks.
It's worth noting that this tracking is property-specific – if your UnreadMessagesModel
had additional properties like messageCount
or lastUpdated
, but you didn't reference them in viewWillLayoutSubviews
, changes to those properties wouldn't trigger layout updates.
So, which update methods participate in this new tracking behavior? While this Is not explicitly documented at the time of this writing, I’ve compiled a summary here thanks to the magic of reverse engineering.
AppKit
For NSViewController
:
For NSView
:
UIKit
For UIViewController
:
updateViewConstraints
viewWillLayoutSubviews
viewDidLayoutSubviews
updateContentUnavailableConfiguration(using:)
For UIView
:
For UITableViewHeaderFooterView
, UIButton
, UICollectionViewCell
, UITableViewCell
:
Backwards compatibility
The new Observation
tracking behavior in both AppKit and UIKit is enabled by default in macOS 26 and iOS 26.
However, the related changes have actually been present in the source since prior year’s macOS 15 and iOS 18 releases – but were off by default. You can turn them on manually and get the same behavior on both OSes by adding the following keys to your Info.plist
:
- For AppKit, add the
NSObservationTrackingEnabled
key and set its value toYES
for compatibility with macOS 15. - For UIKit, add the
UIObservationTrackingEnabled
key and set its value toYES
for compatibility with iOS 18.
New in Swift 6.2
Swift 6.2 saw an implementation of SE-0475: Transactional Observation of Values, adding a general-purpose way to observe changes to Observable
models using an AsyncSequence
. This change closes the remaining gap in the ability to consume continuous changes to Observable
types in arbitrary contexts not limited by the UI framework use cases.
You can create an Observations
instance with a closure that accesses properties it wants to observe – a pattern familiar to Observation
consumers. Subsequently, you can access the AsyncSequence
directly:
let model = UnreadMessagesModel()
let statusChanges = Observations {
model.statusText
}
for await statusText in statusChanges {
print(“Status text is \(statusText).”)
}
The new additions across AppKit, UIKit, and Swift not only close the gaps for general-purpose Observable
consumption use cases, but also make interoperability between UI frameworks vastly easier, providing more flexibility for the ways you work with Observable
types. This is just one of the few changes to SDKs announced during WWDC25 with the common theme of simplifying interoperability and enhancing flexibility – and I’m here for it.