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

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:

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 to YES for compatibility with macOS 15.
  • For UIKit, add the UIObservationTrackingEnabled key and set its value to YES 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.