Skip to content

@Observable support for Jetpack Compose UI#790

Draft
madsodgaard wants to merge 1 commit into
swiftlang:mainfrom
madsodgaard:feature/observable
Draft

@Observable support for Jetpack Compose UI#790
madsodgaard wants to merge 1 commit into
swiftlang:mainfrom
madsodgaard:feature/observable

Conversation

@madsodgaard

@madsodgaard madsodgaard commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Introduces support for backing JetCompose UI with @Observable Swift objects.

This works by using a version-based strategy on the Kotlin side, that effectively increments a Compose integer state everything a change is detected. Changes are detected through the Observations async sequence from Swift. Subscribing to values happens in the generated swift-java getter for any variables on the observable model. This logic is in the TrackingToken type.

This introduces a new Kotlin library SwiftKitCompose which exposes the necessary Kotlin helpers to integrate with the Compose library.

I also added (with Claude's help) a sample app that demonstrates the functionality in various use cases.

We also ignore non-observable stuff like:

  • static properties
  • Immutable variables: let
  • @ObservationIgnored

Code generated

CounterModel.swift:

import Observation

@Observable
public class CounterModel {
  public var count: Int64 = 0

  public init() {}

  public func increment() {
    count += 1
  }

  public func reset() {
    count = 0
  }
}

Use-site:

val model = rememberSwiftObservable { CounterModel.init() }

Generated Java code

// ...
  /** Pointer to the "Subscription" type that is observing changes to this object */
  private long observerPointer;
  
  /** The count of how many are observing changes to this object */
  private int observerCount;
  private final TrackingToken countTracker = new TrackingToken();
  
    @Override
  public void retainObserver() {
    if (observerCount++ == 0) {
      observerPointer = $observe(selfPointer, this);
    }
  }
  
  @Override
  public void releaseObserver() {
    if (--observerCount == 0) {
      $cancelObserve(observerPointer);
      observerPointer = 0;
    }
  }
  
  @Override public void onPropertyChanged(int id) {
    switch (id) {
      case 0 -> countTracker.invalidate();
    }
  }
  
  private static native long $observe(long selfPointer, SwiftObserverCallback cb);
  private static native void $cancelObserve(long observerPointer);

And then we trigger the "listen" inside the getter:

  public long getCount() {
    countTracker.observe();
    return CounterModel.$getCount(this.$memoryAddress());
  }

Generated Swift code

@MainActor
private final class _CounterModelSubscription {
  private let model: CounterModel
  private var tasks: [Task<Void, Never>] = []
  private let callback: JavaSwiftObserverCallback

  init(model: CounterModel, callback: JavaSwiftObserverCallback) {
    self.model = model
    self.callback = callback
  }

  func start() {
    tasks.append(Task { @MainActor [weak self, model = model] in
        // `Observations` emits the current value first.
        // That initial element is not a change, so skip it and only forward
        // subsequent emissions — otherwise every fresh subscription reports a
        // spurious change, which can drive a recomposition feedback loop.
        var isFirstValue = true
        for await _ in Observations({ model.count }) {
          if isFirstValue { isFirstValue = false; continue }
          self?.signal(Int32(0))
        }
    })
  }

  func signal(_ id: Int32) {
    self.callback.onPropertyChanged(id)
  }

  func cancel() {
    tasks.forEach { $0.cancel() }
    tasks.removeAll()
  }
}

#if compiler(>=6.3)
@used
#endif
@_cdecl("Java_com_example_swift_compose_CounterModel__00024observe__JLorg_swift_swiftkit_compose_SwiftObserverCallback_2")
public func Java_com_example_swift_compose_CounterModel__00024observe__JLorg_swift_swiftkit_compose_SwiftObserverCallback_2(environment: UnsafeMutablePointer<JNIEnv?>!, thisClass: jclass, selfPointer: jlong, callback: jobject?) -> jlong {
  guard let env$ = environment else {
    fatalError("Missing JNIEnv in downcall to \(#function)")
  }
  assert(selfPointer != 0, "selfPointer memory address was null")
  let selfPointerBits$ = Int(Int64(fromJNI: selfPointer, in: env$))
  guard let selfPointer$ = UnsafeMutablePointer<CounterModel>(bitPattern: selfPointerBits$) else {
    fatalError("selfPointer memory address was null in call to \(#function)!")
  }
  return MainActor.assumeIsolated {
    let swiftCallback = JavaSwiftObserverCallback(javaThis: callback!, environment: environment)
    let subscription = _CounterModelSubscription(model: selfPointer$.pointee, callback: swiftCallback)
    let unmanaged = Unmanaged.passRetained(subscription)
    let rawPointer = unmanaged.toOpaque()
    subscription.start()
    return jlong(Int(bitPattern: rawPointer))
  }
}

#if compiler(>=6.3)
@used
#endif
@_cdecl("Java_com_example_swift_compose_CounterModel__00024cancelObserve__J")
public func Java_com_example_swift_compose_CounterModel__00024cancelObserve__J(environment: UnsafeMutablePointer<JNIEnv?>!, thisClass: jclass, observerPointer: jlong) {
  let rawPointer = UnsafeRawPointer(bitPattern: Int(observerPointer))!
  let unmanaged = Unmanaged<_CounterModelSubscription>.fromOpaque(rawPointer)
  MainActor.assumeIsolated {
    unmanaged.takeUnretainedValue().cancel()
  }
  unmanaged.release()
} 

implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview)
debugImplementation(libs.androidx.compose.ui.tooling)

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.

AFAICS all these are Apache 2 so we'll be fine, but I need to double check this

}

/// Whether this is a `@ObservationIgnored` type.
var isObservationIgnored: Bool {

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.

Suggested change
var isObservationIgnored: Bool {
var isSwiftObservationIgnored: Bool {

for consistency?

@@ -0,0 +1,6 @@
package org.swift.swiftkit.compose

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.

missing license headers everywhere in kt files

@JavaMethod
public static func equals(environment: UnsafeMutablePointer<JNIEnv?>!, lhsPointer: Int64, lhsTypePointer: Int64, rhsPointer: Int64, rhsTypePointer: Int64) -> Bool {
// Fallback for non-equatable types (such as classes)
let isEquatableByIdentity = lhsPointer == rhsPointer && lhsTypePointer == rhsTypePointer

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 think we landed this on main?

This should be isEqualByIdentity if not; it's not the equate-ability we're checking

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yup ,needs a rebase

@@ -0,0 +1,6 @@
package org.swift.swiftkit.compose

interface SwiftObservable {

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.

Some docs here please

* the bridge calls [invalidate] to bump the counter and trigger
* recomposition of any composable that read it.
*/
class TrackingToken {

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.

Curious what @phausler thinks about the bridging; I guess it's good enough huh.

@marcprux did you do any of such trickery somewhere?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I am not quite sure; when initially prototyping Observation tokens were a more proliferated thing - however it ended up being something that is used less since it is better to have the Observable type manage it's own tracking via the registrar. This does two things, firstly it keeps the developer from needing to hold a bag of tokens around for no real use other than lifetime management, but secondly it avoids a centralized location for tracking because if you shove all observations of all things into one shared center it ends up not scaling easily to large numbers of observers and splitting it out into smaller chunks of observations is more efficient for the lookups for both add and removal of observation.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

That all being said; I am a firm believer in that bridges should favor the semantics they are bridging into not forcing the systems from where they are bridging from into some foreign system.

@ktoso ktoso left a comment

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'll continue later, need to read up about how all this works :)

* the bridge calls [invalidate] to bump the counter and trigger
* recomposition of any composable that read it.
*/
class TrackingToken {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I am not quite sure; when initially prototyping Observation tokens were a more proliferated thing - however it ended up being something that is used less since it is better to have the Observable type manage it's own tracking via the registrar. This does two things, firstly it keeps the developer from needing to hold a bag of tokens around for no real use other than lifetime management, but secondly it avoids a centralized location for tracking because if you shove all observations of all things into one shared center it ends up not scaling easily to large numbers of observers and splitting it out into smaller chunks of observations is more efficient for the lookups for both add and removal of observation.

* the bridge calls [invalidate] to bump the counter and trigger
* recomposition of any composable that read it.
*/
class TrackingToken {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

That all being said; I am a firm believer in that bridges should favor the semantics they are bridging into not forcing the systems from where they are bridging from into some foreign system.

}

private func printObservableSubscriptionType(_ printer: inout CodePrinter, _ type: ImportedNominalType) {
printer.print("@MainActor")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Even though Observation is commonly used (almost exclusively) on the main actor the design does not limit it to just the main actor. Does this somehow restrict the observations to only be main actor? Is that a restriction of the bridged system or is this an imposed restriction to simplify the swift interface?

If it is the latter then I would say we should consider figuring out how to relax that constraint.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I designed the current bridging for specifically Jetpack Compose use. So yeah as it is now, it's intended for UI.

This also affects some of the assumptions for the safety of the variables like the token count of observers, as they are main thread bound. So, it avoids the need for any additional synchronization.

I am not sure if we gain much from making it non UI-specific atm, but it could be an improvement in the future?

private long observerPointer;

/** The count of how many are observing changes to this object */
private int observerCount;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

im not sure this can really be done safely - why is this needed? is it some sort of attempt at meshing GC?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The safety of this relies on the main thread assumption.

But yes, this is used to setup the subscription (Observations sequence) when the first observer wants to listen. There could be multiple views/places that want to observe, so we only store one subscription and we dispose of it when no one is observing anymore.

So yeah, it is kind of GC.

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.

3 participants