diff --git a/Package.swift b/Package.swift index 0d98635..eee7c43 100644 --- a/Package.swift +++ b/Package.swift @@ -17,7 +17,10 @@ let package = Package( .package(url: "https://github.com/skiptools/skip-bridge.git", "0.17.2"..<"2.0.0"), .package(url: "https://github.com/skiptools/skip-android-bridge.git", "0.6.4"..<"2.0.0"), .package(url: "https://github.com/skiptools/swift-jni.git", "0.5.0"..<"2.0.0"), - .package(url: "https://github.com/skiptools/skip-ui.git", from: "1.59.0"), + .package( + url: "https://github.com/tifroz/skip-ui.git", + branch: "experimental_animation-performance" + ), ], targets: [ .target(name: "SkipFuseUI", dependencies: ["SkipSwiftUI"]), diff --git a/README.md b/README.md index d2873b0..656ee56 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,46 @@ When Swift code writes to a `@State` property, the `BridgedStateBox` notifies Co `@Observable` types require `import SkipFuse` to enable this state tracking. See the [App Development](https://skip.dev/docs/app-development/#ui) guide for details. +### Android Recomposition Controls + +Both modifiers can prevent unnecessary Android updates, but they provide different behavior. In SwiftUI terms, `androidEquatable` is an update gate, while `androidCompositionBoundary` gives a subtree its own retained identity and lifecycle. + +| Modifier | SwiftUI mental model | Use when | +| --- | --- | --- | +| `androidEquatable` | Similar to `EquatableView`: while the supplied value remains equal, Skip does not reevaluate the wrapped content. The subtree keeps the identity and lifecycle provided by its existing parent. | The subtree can update as a unit and reevaluating it is expensive. | +| `androidCompositionBoundary` | Similar to placing the subtree in its own retained hosting container. Its `id` defines the container's identity and lifecycle. | The subtree needs an independently retained lifecycle, such as a WebView, map, or video surface. | + +Use `androidEquatable` with a value containing every external input that affects the view: + +```swift +ComplexMetadataPanel(movie: movie) + .androidEquatable(recomposeOverride: movie) +``` + +If `movie` remains equal, the panel is not reevaluated. If it changes, the panel is reevaluated inside its existing parent view hierarchy. This modifier does not create a new identity or lifecycle. + +Use `androidCompositionBoundary` when the subtree should have its own retained identity and lifecycle: + +```swift +BrowserView(url: url) + .androidCompositionBoundary( + id: "browser", + inputs: url.absoluteString + ) +``` + +Think of `id` like SwiftUI view identity and `inputs` as the values used to update that identified view: + +- Same `id`, same `inputs`: reuse the existing host without reevaluating or re-bridging its content. +- Same `id`, changed `inputs`: update the content inside the existing host, preserving its identity and lifecycle. +- Changed `id`, or removal from the hierarchy: dispose the old host and its state, then create a new one if needed. + +State owned inside the boundary can update independently without changing `inputs`. On Apple platforms, `androidCompositionBoundary` returns the original view unchanged. + +The boundary inherits the current Compose composition locals, including bridged SwiftUI environment values. It is a composition and lifecycle boundary, not a layout boundary: modifiers and constraints outside the boundary continue to measure the same retained host and child. Resizing native-backed content therefore does not recreate or re-bridge it, but it still changes the native view's measured bounds. + +Avoid stacking both modifiers around the same subtree without a measured need. An unchanged composition boundary already prevents its child projection from being reevaluated and re-bridged. + ## What SkipFuseUI Covers SkipFuseUI mirrors the SwiftUI API surface for iOS 16+, including: @@ -126,6 +166,8 @@ SkipFuseUI mirrors the SwiftUI API surface for iOS 16+, including: For the full list of supported SwiftUI components, see the [SkipUI documentation](https://skip.dev/docs/modules/skip-ui/#supported-swiftui). +`simultaneousGesture` support follows SkipUI's current Android limitations: it can observe supported gestures on the same rendered view, including drag observation while scroll views continue scrolling, but only `.all` and `.none` have meaningful mask behavior. `.gesture` and `.subviews` masks are not distinguished. + ### Text Selection SkipFuseUI supports programmatic text selection for editable text controls that expose a `TextSelection` binding, such as `TextField` and `TextEditor`. diff --git a/Sources/SkipFuseUI/SwiftUI.swift b/Sources/SkipFuseUI/SwiftUI.swift index ab9a90d..de07126 100644 --- a/Sources/SkipFuseUI/SwiftUI.swift +++ b/Sources/SkipFuseUI/SwiftUI.swift @@ -7,4 +7,39 @@ @_exported import SwiftUI @_exported import struct SkipSwiftUI.TextSelectionIndex @_exported import struct SkipSwiftUI.TextSelectionRange + +extension View where Self: Equatable { + /// Returns this view unchanged on Apple platforms. + /// + /// On Android, the matching `SkipSwiftUI` API skips reevaluating the view while its value + /// remains equal. + nonisolated public func androidEquatable() -> some View { + self + } +} + +extension View { + /// Returns this view unchanged on Apple platforms. + /// + /// On Android, the matching `SkipSwiftUI` API skips reevaluating the view while + /// `recomposeOverride` remains equal. + nonisolated public func androidEquatable( + recomposeOverride: RecomposeOverride + ) -> some View { + self + } + + /// Returns this view unchanged on Apple platforms. + /// + /// On Android, the matching `SkipSwiftUI` API gives the subtree its own retained identity and + /// lifecycle. Keeping `id` stable preserves that host; changing `inputs` updates its content + /// without replacing it. This no-op counterpart lets shared source call the modifier without + /// platform conditionals. + nonisolated public func androidCompositionBoundary( + id: String, + inputs: String = "" + ) -> some View { + self + } +} #endif diff --git a/Sources/SkipSwiftUI/Fuse/AndroidCompositionBoundary.swift b/Sources/SkipSwiftUI/Fuse/AndroidCompositionBoundary.swift new file mode 100644 index 0000000..5fa1894 --- /dev/null +++ b/Sources/SkipSwiftUI/Fuse/AndroidCompositionBoundary.swift @@ -0,0 +1,128 @@ +// Copyright 2026 Skip +// SPDX-License-Identifier: MPL-2.0 +import Foundation +import SkipUI + +/// Retains one projected child per live Compose boundary instance and input value. +internal final class AndroidCompositionBoundaryProjectionRegistry: @unchecked Sendable { + private struct Entry { + let inputs: String + let projection: Projection + } + + private let lock = NSLock() + private var entries: [String: Entry] = [:] + + /// Reuses the installed projection while a boundary's explicit inputs are unchanged. + internal func prepare( + instanceID: String, + inputs: String, + create: () -> Projection + ) -> Projection { + lock.lock() + if let entry = entries[instanceID], entry.inputs == inputs { + lock.unlock() + return entry.projection + } + lock.unlock() + + // Projection can bridge a large view graph, so do that work outside the registry lock. + let projection = create() + lock.lock() + defer { lock.unlock() } + if let entry = entries[instanceID], entry.inputs == inputs { + return entry.projection + } + let entry = Entry(inputs: inputs, projection: projection) + entries[instanceID] = entry + return entry.projection + } + + /// Releases a projection when the owning Compose boundary leaves the hierarchy. + internal func release(instanceID: String) { + lock.lock() + defer { lock.unlock() } + entries.removeValue(forKey: instanceID) + } +} + +/// Owns a projection factory only until SkipUI prepares the current Compose instance. +internal final class AndroidCompositionBoundaryProjectionSource: @unchecked Sendable { + private let lock = NSLock() + private var create: (() -> Projection)? + + internal init(create: @escaping () -> Projection) { + self.create = create + } + + /// Prepares the instance projection, then drops the captured native view graph. + internal func prepare( + in registry: AndroidCompositionBoundaryProjectionRegistry, + instanceID: String, + inputs: String + ) -> Projection { + lock.lock() + let create = self.create + lock.unlock() + + let prepared = registry.prepare(instanceID: instanceID, inputs: inputs) { + guard let create else { + preconditionFailure("Missing projection source for changed boundary inputs") + } + return create() + } + + lock.lock() + self.create = nil + lock.unlock() + return prepared + } +} + +#if os(Android) +private let androidCompositionBoundaryProjectionRegistry = + AndroidCompositionBoundaryProjectionRegistry() +#endif + +extension View { + /// Gives this subtree its own retained identity and lifecycle on Android. + /// + /// Think of the boundary as a separate hosting container. Keeping `id` stable preserves that + /// container and its state. Changing `inputs` updates content inside the existing container; + /// changing `id` disposes it and creates a new one. State owned inside the boundary can update + /// independently without changing `inputs`. The boundary inherits environment values and is + /// transparent to parent measurement: new constraints remeasure the same retained host and + /// child rather than changing their identity. + /// + /// Use this for content such as a WebView, map, or video surface that needs an independently + /// retained lifecycle. To only skip reevaluating ordinary UI while a value remains equal, use + /// `androidEquatable(recomposeOverride:)`. + /// + /// Non-Android platforms return the original view. + nonisolated public func androidCompositionBoundary(id: String, inputs: String = "") -> some View { + #if os(Android) + return ModifierView(target: self) { target in + let projectionSource = AndroidCompositionBoundaryProjectionSource { + target.Java_viewOrEmpty + } + return SkipUI.AndroidCompositionBoundary( + id: id, + inputs: inputs, + bridgedProjectionLifecycle: { instanceID, isDisposing in + if isDisposing { + androidCompositionBoundaryProjectionRegistry.release(instanceID: instanceID) + return SkipUI.EmptyView() + } + return projectionSource.prepare( + in: androidCompositionBoundaryProjectionRegistry, + instanceID: instanceID, + inputs: inputs + ) + } + ) + } + #else + return self + #endif + } +} diff --git a/Sources/SkipSwiftUI/View/AdditionalViewModifiers.swift b/Sources/SkipSwiftUI/View/AdditionalViewModifiers.swift index 3d4007e..35311be 100644 --- a/Sources/SkipSwiftUI/View/AdditionalViewModifiers.swift +++ b/Sources/SkipSwiftUI/View/AdditionalViewModifiers.swift @@ -437,6 +437,7 @@ extension View { return $0.opacity(opacity) } } + } extension View { @@ -543,6 +544,27 @@ extension View { return $0.scaleEffect(x: x, y: y, anchorX: anchor.x, anchorY: anchor.y) } } + + /// Applies scale and translation in one Android compositor layer without relocating layout. + nonisolated public func compositorTransform( + scaleX: CGFloat, + scaleY: CGFloat, + translationX: CGFloat, + translationY: CGFloat, + anchor: UnitPoint = .center + ) -> some View { + return ModifierView(animatableTarget: self) { + return $0.compositorTransform( + scaleX: scaleX, + scaleY: scaleY, + translationX: translationX, + translationY: translationY, + anchorX: anchor.x, + anchorY: anchor.y + ) + } + } + } extension View { diff --git a/Sources/SkipSwiftUI/View/EquatableView.swift b/Sources/SkipSwiftUI/View/EquatableView.swift index 276f71b..19b23b6 100644 --- a/Sources/SkipSwiftUI/View/EquatableView.swift +++ b/Sources/SkipSwiftUI/View/EquatableView.swift @@ -1,5 +1,6 @@ // Copyright 2025–2026 Skip // SPDX-License-Identifier: MPL-2.0 +import SkipBridge import SkipUI @frozen @preconcurrency public struct EquatableView where Content : Equatable, Content : View { @@ -25,3 +26,63 @@ extension View where Self : Equatable { return EquatableView(content: self) } } + +/// Skips reevaluating Android content while its comparison value remains equal. +/// +/// Like SwiftUI's `EquatableView`, this is an update gate within the existing parent view hierarchy. +/// It does not give the subtree a new identity or lifecycle. Use +/// `androidCompositionBoundary(id:inputs:)` when the subtree needs an independently retained one. +@frozen @preconcurrency public struct AndroidEquatableView where Content : View, RecomposeOverride : Equatable { + public var content: Content + public var recomposeOverride: RecomposeOverride + + /// Creates a wrapper that reuses evaluated renderables while `recomposeOverride` remains equal. + @inlinable public init(content: Content, recomposeOverride: RecomposeOverride) { + self.content = content + self.recomposeOverride = recomposeOverride + } +} + +extension AndroidEquatableView : View { + public typealias Body = Never +} + +extension AndroidEquatableView : SkipUIBridging { + /// Preserves the override's Swift `Equatable` implementation across the JVM bridge. + internal var Java_recomposeOverride: SwiftEquatable { + return Java_swiftEquatable(for: recomposeOverride) + } + + public var Java_view: any SkipUI.View { + #if os(Android) + return SkipUI.AndroidEquatableContent( + recomposeOverride: Java_recomposeOverride, + bridgedContentFactory: { content.Java_viewOrEmpty } + ) + #else + return content.Java_viewOrEmpty + #endif + } +} + +extension View where Self : Equatable { + /// On Android, reuses this view's evaluated content inside the parent composition while the + /// view value remains equal. + nonisolated public func androidEquatable() -> AndroidEquatableView { + return AndroidEquatableView(content: self, recomposeOverride: self) + } +} + +extension View { + /// On Android, skips reevaluating this content while `recomposeOverride` remains equal. + /// + /// Include every body-affecting external value in `recomposeOverride`; unchanged values skip + /// parent-driven body evaluation for this subtree. This does not create new view identity or a + /// separate lifecycle. Use `androidCompositionBoundary(id:inputs:)` when the subtree needs an + /// independently retained identity and lifecycle. + nonisolated public func androidEquatable( + recomposeOverride: RecomposeOverride + ) -> AndroidEquatableView { + return AndroidEquatableView(content: self, recomposeOverride: recomposeOverride) + } +} diff --git a/Sources/SkipSwiftUISamples/Fixtures.swift b/Sources/SkipSwiftUISamples/Fixtures.swift index acc0383..f29586a 100644 --- a/Sources/SkipSwiftUISamples/Fixtures.swift +++ b/Sources/SkipSwiftUISamples/Fixtures.swift @@ -176,3 +176,29 @@ public struct ConditionalContentTestFixture: View { } } } + +/// Verifies that `androidEquatable` preserves full Swift equality when an `Identifiable` value +/// changes without changing its ID. +public struct AndroidEquatableIdentityTestFixture: View { + struct Model: Identifiable, Equatable { + let id: Int + var title: String + } + + @State var model = Model(id: 1, title: "A") + + public init() { + } + + public var body: some View { + VStack { + Text(model.title) + .accessibilityIdentifier("android-equatable-identity-title") + .androidEquatable(recomposeOverride: model) + Button("update title") { + model.title = "B" + } + .accessibilityIdentifier("android-equatable-identity-update") + } + } +} diff --git a/Sources/SkipSwiftUISamples/TestSupport.swift b/Sources/SkipSwiftUISamples/TestSupport.swift index 9fea526..85ac105 100644 --- a/Sources/SkipSwiftUISamples/TestSupport.swift +++ b/Sources/SkipSwiftUISamples/TestSupport.swift @@ -1,44 +1,21 @@ // Copyright 2026 Skip // SPDX-License-Identifier: MPL-2.0 -#if canImport(Darwin) -import Darwin -#elseif canImport(Glibc) -import Glibc -#endif - /// Native support for the transpiled `SkipSwiftUISamplesTests` Compose UI tests. public final class SkipSwiftUITestSupport { - /// Prepare a JVM-hosted (Robolectric) test environment for bridged main-actor calls. + /// Whether the current test environment supports bridged main-actor calls. /// /// On Android the main looper drains the dispatch main queue, so the main-actor - /// assumptions made by bridged calls hold on the UI thread and this is a no-op. A JVM - /// hosted on a development OS has no such integration: Robolectric runs everything on a - /// JVM test thread that is never the platform main queue, so the first bridged call into - /// `@MainActor` API would trap in the runtime's last-resort `checkIsolated` check. - /// Installing a no-op `swift_task_checkIsolated_hook` makes the runtime treat that check - /// as satisfied; Robolectric tests are effectively single-threaded, so the assumption is - /// sound in practice. + /// assumptions made by bridged calls hold on the UI thread. A JVM hosted on a development + /// OS has no such integration: Robolectric's UI thread is not Swift's main executor, so + /// entering generated `@MainActor` bridge code would trap. /// - /// Returns false when the relaxation could not be installed (a host Swift runtime that - /// predates the hook); callers should skip bridged-UI tests in that case rather than crash. + /// Callers should skip bridged-UI tests when this returns false and run them on an Android + /// device or emulator by setting `ANDROID_SERIAL`. public static func prepareJVMHostedTesting() -> Bool { #if os(Android) return true #else - #if canImport(Darwin) - let defaultHandle = UnsafeMutableRawPointer(bitPattern: -2) // RTLD_DEFAULT - #else - let defaultHandle: UnsafeMutableRawPointer? = nil // RTLD_DEFAULT - #endif - guard let hook = dlsym(defaultHandle, "swift_task_checkIsolated_hook") else { - return false - } - // The hook is `SWIFT_CC(swift) (SerialExecutorRef, original) -> Void`: two register - // words plus the original-function pointer, all ignored by the no-op, so a - // C-convention function with matching register usage is ABI-compatible. - let noop: @convention(c) (UnsafeRawPointer?, UnsafeRawPointer?, UnsafeRawPointer?) -> Void = { _, _, _ in } - hook.assumingMemoryBound(to: UnsafeRawPointer?.self).pointee = unsafeBitCast(noop, to: UnsafeRawPointer?.self) - return true + return false #endif } } diff --git a/Tests/SkipSwiftUISamplesTests/FuseComposeUITests.swift b/Tests/SkipSwiftUISamplesTests/FuseComposeUITests.swift index c8ce7df..422d168 100644 --- a/Tests/SkipSwiftUISamplesTests/FuseComposeUITests.swift +++ b/Tests/SkipSwiftUISamplesTests/FuseComposeUITests.swift @@ -120,6 +120,26 @@ final class FuseComposeUITests: XCTestCase { #endif } + /// A bridged `Identifiable & Equatable` override must compare its complete Swift value rather + /// than collapsing to its stable ID. + func testAndroidEquatablePreservesSwiftEqualityBeyondIdentifiableID() throws { + #if !SKIP + throw XCTSkip("Compose UI testing is Android-only") + #else + try requireBridgedMainActor() + composeRule.setContent { + AndroidEquatableIdentityTestFixture().Compose() + } + composeRule.waitForIdle() + composeRule.onNodeWithTag("android-equatable-identity-title").assertTextEquals("A") + + composeRule.onNodeWithTag("android-equatable-identity-update").performClick() + composeRule.waitForIdle() + + composeRule.onNodeWithTag("android-equatable-identity-title").assertTextEquals("B") + #endif + } + /// The two-square invariant, end-to-end through the bridge: clicking the button toggles /// `animated` inside `withAnimation(.linear(duration: 1))` and `unrelated` outside it. /// The unrelated rect must snap to its target immediately while the animated rect diff --git a/Tests/SkipSwiftUITests/AndroidCompositionBoundaryProjectionRegistryTests.swift b/Tests/SkipSwiftUITests/AndroidCompositionBoundaryProjectionRegistryTests.swift new file mode 100644 index 0000000..0f53147 --- /dev/null +++ b/Tests/SkipSwiftUITests/AndroidCompositionBoundaryProjectionRegistryTests.swift @@ -0,0 +1,153 @@ +// Copyright 2026 Skip +// SPDX-License-Identifier: MPL-2.0 +import XCTest +#if !SKIP +@testable import SkipSwiftUI + +final class AndroidCompositionBoundaryProjectionRegistryTests: XCTestCase { + private final class ProjectionProbe {} + private final class ProjectionFactoryCapture {} + + func testUnchangedInputsReuseExistingProjection() { + let registry = AndroidCompositionBoundaryProjectionRegistry() + var creationCount = 0 + + let firstProjection = registry.prepare(instanceID: "instance-1", inputs: "tab-1") { + creationCount += 1 + return "first" + } + let secondProjection = registry.prepare(instanceID: "instance-1", inputs: "tab-1") { + creationCount += 1 + return "second" + } + + XCTAssertEqual(firstProjection, "first") + XCTAssertEqual(secondProjection, "first") + XCTAssertEqual(creationCount, 1) + } + + func testChangedInputsReplaceProjection() { + let registry = AndroidCompositionBoundaryProjectionRegistry() + + let firstProjection = registry.prepare(instanceID: "instance-1", inputs: "tab-1") { + "first" + } + let secondProjection = registry.prepare(instanceID: "instance-1", inputs: "tab-2") { + "second" + } + + XCTAssertEqual(firstProjection, "first") + XCTAssertEqual(secondProjection, "second") + } + + func testChangedInputsReleasePreviousProjectionWithinSameInstance() { + let registry = AndroidCompositionBoundaryProjectionRegistry() + weak var releasedFirstProjection: ProjectionProbe? + weak var retainedSecondProjection: ProjectionProbe? + + do { + let firstProjection = ProjectionProbe() + releasedFirstProjection = firstProjection + _ = registry.prepare(instanceID: "instance-1", inputs: "tab-1") { + firstProjection + } + } + + XCTAssertNotNil(releasedFirstProjection) + + do { + let secondProjection = ProjectionProbe() + retainedSecondProjection = secondProjection + _ = registry.prepare(instanceID: "instance-1", inputs: "tab-2") { + secondProjection + } + } + + XCTAssertNil(releasedFirstProjection) + XCTAssertNotNil(retainedSecondProjection) + + registry.release(instanceID: "instance-1") + XCTAssertNil(retainedSecondProjection) + } + + func testSeparateComposeInstancesDoNotShareProjection() { + let registry = AndroidCompositionBoundaryProjectionRegistry() + + let firstProjection = registry.prepare(instanceID: "instance-1", inputs: "") { "first" } + let secondProjection = registry.prepare(instanceID: "instance-2", inputs: "") { "second" } + registry.release(instanceID: "instance-1") + let retainedSecondProjection = registry.prepare(instanceID: "instance-2", inputs: "") { + "replacement" + } + + XCTAssertEqual(firstProjection, "first") + XCTAssertEqual(secondProjection, "second") + XCTAssertEqual(retainedSecondProjection, "second") + } + + func testReleaseDropsTheRegistrysStrongReference() { + let registry = AndroidCompositionBoundaryProjectionRegistry() + weak var releasedProjection: ProjectionProbe? + + do { + let projection = ProjectionProbe() + releasedProjection = projection + _ = registry.prepare(instanceID: "instance-1", inputs: "tab-1") { projection } + } + + XCTAssertNotNil(releasedProjection) + registry.release(instanceID: "instance-1") + XCTAssertNil(releasedProjection) + } + + func testProjectionSourceDropsCapturedViewGraphAfterPreparation() { + let registry = AndroidCompositionBoundaryProjectionRegistry() + weak var releasedCapture: ProjectionFactoryCapture? + let source: AndroidCompositionBoundaryProjectionSource + + do { + let capture = ProjectionFactoryCapture() + releasedCapture = capture + source = AndroidCompositionBoundaryProjectionSource { + _ = capture + return "projection" + } + } + + XCTAssertNotNil(releasedCapture) + let projection = source.prepare( + in: registry, + instanceID: "instance-1", + inputs: "" + ) + + XCTAssertEqual(projection, "projection") + XCTAssertNil(releasedCapture) + } + + func testProjectionSourceDropsUnneededCaptureWhenProjectionIsReused() { + let registry = AndroidCompositionBoundaryProjectionRegistry() + _ = registry.prepare(instanceID: "instance-1", inputs: "") { "retained" } + + weak var releasedCapture: ProjectionFactoryCapture? + let source: AndroidCompositionBoundaryProjectionSource + do { + let capture = ProjectionFactoryCapture() + releasedCapture = capture + source = AndroidCompositionBoundaryProjectionSource { + _ = capture + return "replacement" + } + } + + let projection = source.prepare( + in: registry, + instanceID: "instance-1", + inputs: "" + ) + + XCTAssertEqual(projection, "retained") + XCTAssertNil(releasedCapture) + } +} +#endif diff --git a/Tests/SkipSwiftUITests/AndroidEquatableViewTests.swift b/Tests/SkipSwiftUITests/AndroidEquatableViewTests.swift new file mode 100644 index 0000000..0e29fe6 --- /dev/null +++ b/Tests/SkipSwiftUITests/AndroidEquatableViewTests.swift @@ -0,0 +1,38 @@ +// Copyright 2026 Skip +// SPDX-License-Identifier: MPL-2.0 +import XCTest + +#if !SKIP +@testable import SkipSwiftUI + +final class AndroidEquatableViewTests: XCTestCase { + private struct Model: Identifiable, Equatable { + let id: Int + let title: String + } + + func testBridgedOverridePreservesEqualityBeyondIdentifiableID() { + let original = AndroidEquatableView( + content: EmptyView(), + recomposeOverride: Model(id: 1, title: "A") + ) + let equal = AndroidEquatableView( + content: EmptyView(), + recomposeOverride: Model(id: 1, title: "A") + ) + let changed = AndroidEquatableView( + content: EmptyView(), + recomposeOverride: Model(id: 1, title: "B") + ) + + XCTAssertEqual(original.Java_recomposeOverride, equal.Java_recomposeOverride) + XCTAssertNotEqual(original.Java_recomposeOverride, changed.Java_recomposeOverride) + + // The previous string conversion collapsed both values to the same Identifiable ID. + XCTAssertEqual( + Java_composeBundleString(for: original.recomposeOverride), + Java_composeBundleString(for: changed.recomposeOverride) + ) + } +} +#endif