Skip to content

Add compatibilty with Swift's @Observable macro#526

Open
s-k wants to merge 2 commits into
moreSwift:mainfrom
s-k:observable-support
Open

Add compatibilty with Swift's @Observable macro#526
s-k wants to merge 2 commits into
moreSwift:mainfrom
s-k:observable-support

Conversation

@s-k
Copy link
Copy Markdown

@s-k s-k commented Apr 23, 2026

Resolves #407.

This pull request does the following:

  • A view now automatically updates when @Observable or @Perceptible objects used inside the view's body change.
  • Adds an example illustrating the new behavior.
  • Removes the Observable typealias because it would clash with the new functionality.
  • Obsoletes the ObservationIgnored macro in OSs where the Observation framework is available. This fixes a problem where this macro would clash with the ObservationIgnored macro from the Observation framework (see Add compatibilty with Swift's @Observable macro #407). In my testing, this solution fully fixes the clash. In OSs where Observation is available, users may need to import Observation to continue using @ObservationIgnored together with @ObservableObject. The obsoletion error message also instructs users to do so.

I have split the PR into two commits. The first one contains a straightforward implementation of the new functionality. The second one simplifies the implementation using a protocol. If you prefer a straightforward solution, the second commit could be dropped. It adds no new functionality.

s-k added 2 commits April 21, 2026 11:16
Views now update automatically when an `@Observable` model class used
inside `body` changes. The same is true for `@Perceptible`.

Adds a `@Bindable` property wrapper so that properties of observable
models can be easily used as bindings.
@JoshBashed
Copy link
Copy Markdown

Does this support OpenCombine?

@s-k
Copy link
Copy Markdown
Author

s-k commented Apr 28, 2026

@JoshBashed This PR tries to add compatibility with @Observable similar to how SwiftUI supports it. AFAIK, OpenCombine works very differently compared to the Observation framework. I'm not sure what kind of support you expect.

Copy link
Copy Markdown
Collaborator

@stackotter stackotter left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for this PR 🙏 I particularly like the ViewModelObserver protocol that you've used to make observation more succinct.

I've left a bunch of comments requesting a variety of changes, the biggest being potentially splitting perceptible support into a separate PR so that I can merge Observable support faster.

There was one other thing that I didn't comment on with a review comment, because I didn't know where to attach it. I would like for us to be vend an @Observable implementation (borrowed from Swift itself) to support @Observable as our official observation solution across all OS versions supported by SwiftCrossUI. My guess is that that would involve a bunch of copy+pasting (with care taken to give correct attribution and separate it from our own code a bit), and fixing up the implementations to not require newer language features. Then if you do that we should @_reexport import Observation on platforms that do have Observation so that people don't have to conditionally import Observation themselves based on target platform/platform version. Let me know if any of that doesn't make sense or isn't feasible!

Thanks for the detailed documentation by the way!

}
ModifyingView(model: model)
.padding()
if !model.automaticModeIsOn {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please make it possible to disable automatic mode again without restarting the app? Will need to store a reference to the automatic task, and then can probably cancel it from an 'onChange(of: automaticModeIsOn)' handler

Comment on lines +8 to +9
/// `@Perceptible` from the
/// [Perception](https://github.com/pointfreeco/swift-perception) package.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If perception gets split into a separate PR, remember to update these doc comments

}

extension Bindable : Identifiable where Value : Identifiable {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove this blank line

Comment thread Package.swift
Comment on lines +162 to +165
.package(
url: "https://github.com/pointfreeco/swift-perception.git",
from: "2.0.10"
),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe that swift-perception support should be behind a trait, as it won't be our recommended solution and is more of a convenience integration for people who want to use swift-perception anyway. Our Swift tools version is currently too low for traits, but it's getting to a point where we should probably consider bumping our tools version (there have been quite a few good reasons lately).

Would you feel comfortable splitting the swift-perception support into a separate PR so we can merge Observable support without having to worry about the Swift tools version bump?

/// let body = self.observe(in: backend) { view.body }
/// // Use `body`
///
/// Then, `viewModelDidChange()` will automatically be called the next time a view model conforming
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/// Then, ``viewModelDidChange(backend:)`` ...

(that should link the docs to the method correctly)

/// The dynamic property updater for this view.
private var dynamicPropertyUpdater: DynamicPropertyUpdater<NodeView>

/// Used by the `ViewModelObserver` protocol to prevent duplicate view updates.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use double backticks to create a DocC symbol link

implementation = StateImpl(initialStorage: Storage(initialValue))
}

#if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) || os(visionOS)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just as a formatting thing we generally indent compile time conditionals to match surrounding code (with the body indented an additional level)

implementation = StateImpl(initialStorage: Storage(initialValue))
}

#if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) || os(visionOS)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that this is an 'Apple platforms' issue, you could consider using #if canImport(Darwin) to be resilient against future Apple platforms being introduced (such as would've happened to this same code a couple of years ago when visionOS was introduced)

import Foundation
import PerceptionCore

/// This protocol can be adoopted by classes responsible for handling part of the view hierarchy. It makes
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

adopted (typo)

}
}

extension Bindable where Value : AnyObject {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We only put a space after colons (and not before them). I think you've done this in a couple of other places so just double check the rest of the PR as well (can probably just command+f for :)

@s-k
Copy link
Copy Markdown
Author

s-k commented Apr 29, 2026

@stackotter Thank you for the kind words! And thanks for the detailed response. I'm happy to implement the changes. There's one thing I'm not 100% clear on and I want to clear that up before starting: You suggest removing Perceptible support. Do you only refer to the signatures I added that mention Perceptible or do you want me to remove the dependency on swift-perception as well? It seems that you intend the latter. If that's the case, then we might have a problem. Here's the thing:

swift-perception does all the heavy lifting behind the scenes: It allows Observable-style observation on older OSs where the Observation framework is not available. It also automatically switches to using the Observation framework where it is available. This means that we can't simply drop the swift-perception dependency or hide it behind a trait. I believe we have four options:

  1. Copy a bunch of code from the Observation framework (and/or) swift-perception into the project and use it only if Observation isn't available
  2. Only support Observation and only where it is available
  3. Leave the implementation largely as is and optionally remove any mention of @Perceptible in the documentation
  4. The same as Option 3 but add typealiases so that @Observable, etc. are also available on older OSs with the same names.

Option 1 would be by far the most complicated and maintenance-heavy option. And the only benefit over Option 4 that I can think of is that the project would have one fewer dependency. It would take a lot of work to implement and test and the added code would need maintenance.

Option 2 would probably be relatively straightforward. We would lose the dependency but also the ability to observe on older OSs.

Option 3 allows all users to use the observation functionality. If they use @Perceptible, they will automatically use @Observable under the hood where available. And if they use @Perceptible in a case where the app only supports newer OSs, they will get a warning to switch to @Observable.

Option 4 has the same benefits as Option 3 but would allow users to use @Observable on all OSs. We have to be cautious to add the typealiases in a way that they don't cause naming conflicts in some configurations. However, I think it could be done.

What do you think?

@stackotter
Copy link
Copy Markdown
Collaborator

Ah I didn't quite realise that swift-perception basically does exactly what I proposed with backporting the official Swift Observation implementation. I think option 4 is best. By default we should only expose @Observable (and withObservationTracking, etc), but if the user imports Perceptible, then those macros should work all the same (which should happen by default given that we'll be using Perceptible to implement things anyway).

Are there any Observation APIs that swift-perception is missing? I guess we'll just have to stay on top of new Observation feature proposals and make sure that swift-perception gets corresponding implementations before the features officially land in Swift?

@s-k
Copy link
Copy Markdown
Author

s-k commented May 1, 2026

@stackotter Great, we're on the same page! I'll try to get to it next week.

I believe swift-perception supports all APIs from Observation, but I'll check. It even has additional ones. My impression is that the library is well-maintained. Also, I'm not sure it's crucial that swift-perception is at parity with all Observation APIs. The important thing is that withPerceptionTracking() triggers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add compatibilty with Swift's @Observable macro

3 participants