From 4d62f7c286305e53e28aa8ff997ebbdb211e4ddd Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Wed, 22 Jul 2026 20:39:46 -0400 Subject: [PATCH 1/7] Add the bridge constructor to ViewNode One JNI call per node with flat arrays; prop and modifier-arg values arrive as JSON literals, keeping the typed model without per-field crossings. --- .../kotlin/com/pureswift/swiftui/ViewNode.kt | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/ViewNode.kt b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/ViewNode.kt index 9edd2e1..2b3cbdb 100644 --- a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/ViewNode.kt +++ b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/ViewNode.kt @@ -2,10 +2,12 @@ package com.pureswift.swiftui import androidx.compose.runtime.Immutable import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.longOrNull /// One entry in a node's ordered modifier chain. Order is significant: the @@ -39,6 +41,33 @@ data class ViewNode( val itemProviderId: Long? = null, ) { + /// Bridge constructor: the Swift materializer builds nodes through this, + /// crossing JNI with flat arrays (one call per node, arrays as single + /// arguments). Prop and modifier-arg values arrive as JSON literals + /// ("\"text\"", "42", "true"), keeping the typed JsonObject model without + /// per-field JNI calls. Negative count/provider mean "absent". + constructor( + type: String, + id: String, + propKeys: Array, + propValues: Array, + modifierKinds: Array, + modifierArgs: Array, + children: Array, + count: Int, + itemProviderId: Long, + ) : this( + type = type, + id = id, + props = JsonObject(propKeys.indices.associate { propKeys[it] to Json.parseToJsonElement(propValues[it]) }), + modifiers = modifierKinds.indices.map { + ModifierNode(modifierKinds[it], Json.parseToJsonElement(modifierArgs[it]).jsonObject) + }, + children = children.toList(), + count = if (count >= 0) count else null, + itemProviderId = if (itemProviderId >= 0) itemProviderId else null, + ) + // Typed prop accessors used by the interpreter. fun string(key: String): String? = (props[key] as? JsonPrimitive)?.content From 167e0278559f68e17617578c5463a503b3fab524 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Wed, 22 Jul 2026 20:39:46 -0400 Subject: [PATCH 2/7] Add the Swift callback sink The entire Kotlin-to-Swift surface: five externals in a JVM source set both targets share, dispatching event ids into the Swift registry. --- Demo/swiftui/build.gradle.kts | 9 +++++++++ .../pureswift/swiftui/SwiftCallbackSink.kt | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 Demo/swiftui/src/jvmShared/kotlin/com/pureswift/swiftui/SwiftCallbackSink.kt diff --git a/Demo/swiftui/build.gradle.kts b/Demo/swiftui/build.gradle.kts index f05b3e6..8e5fe6a 100644 --- a/Demo/swiftui/build.gradle.kts +++ b/Demo/swiftui/build.gradle.kts @@ -28,6 +28,15 @@ kotlin { implementation(compose.material3) implementation(libs.kotlinx.serialization.json) } + // `external fun` is JVM-only; both targets are JVM, so the bridge's + // Swift-implemented classes live in a source set they share. + val jvmShared by creating { + dependsOn(commonMain.get()) + } + androidMain.get().dependsOn(jvmShared) + val desktopMain by getting { + dependsOn(jvmShared) + } val desktopTest by getting { dependencies { implementation(kotlin("test")) diff --git a/Demo/swiftui/src/jvmShared/kotlin/com/pureswift/swiftui/SwiftCallbackSink.kt b/Demo/swiftui/src/jvmShared/kotlin/com/pureswift/swiftui/SwiftCallbackSink.kt new file mode 100644 index 0000000..5bad982 --- /dev/null +++ b/Demo/swiftui/src/jvmShared/kotlin/com/pureswift/swiftui/SwiftCallbackSink.kt @@ -0,0 +1,19 @@ +package com.pureswift.swiftui + +// The entire Kotlin→Swift bridge surface: five externals dispatching event +// callback ids into the Swift registry. The JNI symbol for each derives from +// the SWIFT @JavaImplementation signature — these declarations and the Swift +// counterparts in SwiftCallbackSink.swift must stay exactly in sync, and this +// class must never grow per-view methods. +class SwiftCallbackSink : CallbackSink { + + external override fun invokeVoid(id: Long) + + external override fun invokeBool(id: Long, value: Boolean) + + external override fun invokeDouble(id: Long, value: Double) + + external override fun invokeInt(id: Long, value: Int) + + external override fun invokeString(id: Long, value: String) +} From fca2cb8a324f046b06f8b7c9c93f1c0612fbb9b7 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Wed, 22 Jul 2026 20:39:46 -0400 Subject: [PATCH 3/7] Add the JNI bridge between the core and the interpreter Platform-neutral target: Kotlin bindings for ViewNode and TreeStore, a materializer walking the IR into Kotlin nodes, the Swift half of the callback sink, and a runtime driving evaluate-materialize-update on every state change. Builds as a macOS dylib and cross-compiles for Android identically. --- Package.swift | 46 +++++++++- .../AndroidSwiftUIBridge/BridgeRuntime.swift | 52 ++++++++++++ .../AndroidSwiftUIBridge/KotlinBindings.swift | 41 +++++++++ .../AndroidSwiftUIBridge/Materializer.swift | 84 +++++++++++++++++++ .../SwiftCallbackSink.swift | 44 ++++++++++ 5 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 Sources/AndroidSwiftUIBridge/BridgeRuntime.swift create mode 100644 Sources/AndroidSwiftUIBridge/KotlinBindings.swift create mode 100644 Sources/AndroidSwiftUIBridge/Materializer.swift create mode 100644 Sources/AndroidSwiftUIBridge/SwiftCallbackSink.swift diff --git a/Package.swift b/Package.swift index e098145..bfd8a74 100644 --- a/Package.swift +++ b/Package.swift @@ -13,18 +13,39 @@ let package = Package( .library( name: "AndroidSwiftUI", targets: ["AndroidSwiftUI"] + ), + .library( + name: "AndroidSwiftUIBridge", + targets: ["AndroidSwiftUIBridge"] + ), + // Desktop test-rig dylib: the bridge plus a demo root view, loaded by + // the Compose Desktop rig on the host JVM. + .library( + name: "SwiftUIDesktopDemo", + type: .dynamic, + targets: ["SwiftUIDesktopDemo"] ) ], dependencies: [ .package( url: "https://github.com/PureSwift/Android.git", branch: "master" - ) + ), + .package( + url: "https://github.com/swiftlang/swift-java.git", + branch: "main" + ), + .package(path: "AndroidSwiftUICore") ], targets: [ .target( name: "AndroidSwiftUI", dependencies: [ + "AndroidSwiftUIBridge", + .product( + name: "AndroidSwiftUICore", + package: "AndroidSwiftUICore" + ), .product( name: "AndroidKit", package: "Android" @@ -33,6 +54,29 @@ let package = Package( swiftSettings: [ .swiftLanguageMode(.v5) ] + ), + // JNI bridge between the evaluation core and the Compose interpreter. + // Platform-neutral: no android.* imports, so it builds for the desktop + // JVM (macOS dylib) and cross-compiles for Android identically. + .target( + name: "AndroidSwiftUIBridge", + dependencies: [ + .product(name: "AndroidSwiftUICore", package: "AndroidSwiftUICore"), + .product(name: "SwiftJava", package: "swift-java") + ], + swiftSettings: [ + .swiftLanguageMode(.v5) + ] + ), + .target( + name: "SwiftUIDesktopDemo", + dependencies: [ + "AndroidSwiftUIBridge", + .product(name: "AndroidSwiftUICore", package: "AndroidSwiftUICore") + ], + swiftSettings: [ + .swiftLanguageMode(.v5) + ] ) ] ) diff --git a/Sources/AndroidSwiftUIBridge/BridgeRuntime.swift b/Sources/AndroidSwiftUIBridge/BridgeRuntime.swift new file mode 100644 index 0000000..13c30c4 --- /dev/null +++ b/Sources/AndroidSwiftUIBridge/BridgeRuntime.swift @@ -0,0 +1,52 @@ +// +// BridgeRuntime.swift +// AndroidSwiftUIBridge +// +// Drives a ViewHost against a Kotlin TreeStore: evaluate → materialize → +// update, re-running on every state change. +// + +import AndroidSwiftUICore + +public final class BridgeRuntime { + + /// The active runtime; the callback sink dispatches into its registry. + /// One UI tree per process for now (matches one Activity / one window). + public private(set) static var current: BridgeRuntime? + + let host: ViewHost + let store: TreeStore + + public init(root: any View, store: TreeStore) { + self.host = ViewHost(root) + self.store = store + // UI events arrive on the platform main thread (Compose dispatch), and + // state writes inside them re-evaluate synchronously here. Event + // callbacks run outside composition, so assigning the store's Compose + // state from them is the standard, safe path. Cross-thread writes get + // a scheduler when async state arrives (R5). + self.host.onStateChange = { [weak self] in + self?.push() + } + } + + /// Installs this runtime as the process-wide dispatch target and renders + /// the first tree. + public func start() { + BridgeRuntime.current = self + push() + } + + func push() { + let tree = host.evaluate() + store.update(Materializer.materialize(tree)) + } + + // Dispatch entry points, called by the callback sink. + + public func invokeVoid(_ id: Int64) { host.callbacks.invokeVoid(id) } + public func invokeBool(_ id: Int64, _ value: Bool) { host.callbacks.invokeBool(id, value) } + public func invokeDouble(_ id: Int64, _ value: Double) { host.callbacks.invokeDouble(id, value) } + public func invokeInt(_ id: Int64, _ value: Int) { host.callbacks.invokeInt(id, value) } + public func invokeString(_ id: Int64, _ value: String) { host.callbacks.invokeString(id, value) } +} diff --git a/Sources/AndroidSwiftUIBridge/KotlinBindings.swift b/Sources/AndroidSwiftUIBridge/KotlinBindings.swift new file mode 100644 index 0000000..4028a34 --- /dev/null +++ b/Sources/AndroidSwiftUIBridge/KotlinBindings.swift @@ -0,0 +1,41 @@ +// +// KotlinBindings.swift +// AndroidSwiftUIBridge +// +// Swift→Kotlin bindings for the interpreter's node model. This is the safe +// bridge direction: methods resolve by name+signature lookup and fail loudly +// on mismatch. +// + +import SwiftJava + +/// Binding for `com.pureswift.swiftui.ViewNode`. +@JavaClass("com.pureswift.swiftui.ViewNode") +open class ViewNodeObject: JavaObject { + + /// The bridge constructor: one JNI call per node, arrays crossing as + /// single arguments. Prop/modifier-arg values are JSON literals; negative + /// count/provider mean "absent". + @JavaMethod + @_nonoverride public convenience init( + _ type: String, + _ id: String, + _ propKeys: [String], + _ propValues: [String], + _ modifierKinds: [String], + _ modifierArgs: [String], + _ children: [ViewNodeObject?], + _ count: Int32, + _ itemProviderId: Int64, + environment: JNIEnvironment? = nil + ) +} + +/// Binding for `com.pureswift.swiftui.TreeStore`. +@JavaClass("com.pureswift.swiftui.TreeStore") +open class TreeStore: JavaObject { + + /// Assigns a freshly materialized tree; Compose recomposes changed subtrees. + @JavaMethod + open func update(_ node: ViewNodeObject?) +} diff --git a/Sources/AndroidSwiftUIBridge/Materializer.swift b/Sources/AndroidSwiftUIBridge/Materializer.swift new file mode 100644 index 0000000..04d6912 --- /dev/null +++ b/Sources/AndroidSwiftUIBridge/Materializer.swift @@ -0,0 +1,84 @@ +// +// Materializer.swift +// AndroidSwiftUIBridge +// +// Walks the core's RenderNode IR and constructs the interpreter's Kotlin +// ViewNode objects — the typed transport that replaces string serialization. +// + +import AndroidSwiftUICore +import SwiftJava + +public enum Materializer { + + /// Builds the Kotlin mirror of an IR tree, depth-first. + public static func materialize(_ node: RenderNode) -> ViewNodeObject { + var propKeys: [String] = [] + var propValues: [String] = [] + for (key, value) in node.props { + propKeys.append(key) + propValues.append(jsonLiteral(value)) + } + var modifierKinds: [String] = [] + var modifierArgs: [String] = [] + for modifier in node.modifiers { + modifierKinds.append(modifier.kind) + modifierArgs.append(jsonObjectLiteral(modifier.args)) + } + let children = node.children.map { materialize($0) as ViewNodeObject? } + return ViewNodeObject( + node.type, + node.id, + propKeys, + propValues, + modifierKinds, + modifierArgs, + children, + Int32(node.count ?? -1), + Int64(-1) + ) + } + + /// Encodes a prop value as a JSON literal (`"text"`, `42`, `true`, `[…]`). + static func jsonLiteral(_ value: PropValue) -> String { + switch value { + case .string(let string): + return escapeJSON(string) + case .double(let double): + return "\(double)" + case .int(let int): + return "\(int)" + case .bool(let bool): + return bool ? "true" : "false" + case .array(let values): + return "[" + values.map(jsonLiteral).joined(separator: ",") + "]" + } + } + + static func jsonObjectLiteral(_ args: [String: PropValue]) -> String { + let members = args.map { "\(escapeJSON($0.key)):\(jsonLiteral($0.value))" } + return "{" + members.joined(separator: ",") + "}" + } + + /// Minimal JSON string escaping (quotes, backslashes, control characters). + static func escapeJSON(_ string: String) -> String { + var out = "\"" + for scalar in string.unicodeScalars { + switch scalar { + case "\"": out += "\\\"" + case "\\": out += "\\\\" + case "\n": out += "\\n" + case "\r": out += "\\r" + case "\t": out += "\\t" + default: + if scalar.value < 0x20 { + let hex = String(scalar.value, radix: 16) + out += "\\u" + String(repeating: "0", count: 4 - hex.count) + hex + } else { + out.unicodeScalars.append(scalar) + } + } + } + return out + "\"" + } +} diff --git a/Sources/AndroidSwiftUIBridge/SwiftCallbackSink.swift b/Sources/AndroidSwiftUIBridge/SwiftCallbackSink.swift new file mode 100644 index 0000000..682a55d --- /dev/null +++ b/Sources/AndroidSwiftUIBridge/SwiftCallbackSink.swift @@ -0,0 +1,44 @@ +// +// SwiftCallbackSink.swift +// AndroidSwiftUIBridge +// +// The entire Kotlin→Swift bridge surface. The JNI symbol for each method +// derives from THIS signature — the Kotlin `external` declarations in +// SwiftCallbackSink.kt must stay exactly in sync, and this class must never +// grow per-view methods. +// + +import SwiftJava + +@JavaClass("com.pureswift.swiftui.SwiftCallbackSink") +open class SwiftCallbackSink: JavaObject { +} + +@JavaImplementation("com.pureswift.swiftui.SwiftCallbackSink") +extension SwiftCallbackSink { + + @JavaMethod + func invokeVoid(_ id: Int64) { + BridgeRuntime.current?.invokeVoid(id) + } + + @JavaMethod + func invokeBool(_ id: Int64, _ value: Bool) { + BridgeRuntime.current?.invokeBool(id, value) + } + + @JavaMethod + func invokeDouble(_ id: Int64, _ value: Double) { + BridgeRuntime.current?.invokeDouble(id, value) + } + + @JavaMethod + func invokeInt(_ id: Int64, _ value: Int32) { + BridgeRuntime.current?.invokeInt(id, Int(value)) + } + + @JavaMethod + func invokeString(_ id: Int64, _ value: String) { + BridgeRuntime.current?.invokeString(id, value) + } +} From 515a7721875d3bb8f8ef331241fa46a9f4c28267 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Wed, 22 Jul 2026 20:39:46 -0400 Subject: [PATCH 4/7] Drive the desktop rig from the Swift dylib Loading libSwiftJava first fires swift-java's JNI_OnLoad; the rig then hands its tree store to the Swift entry point and renders live trees, falling back to fixtures when no library is configured. --- Demo/desktop/build.gradle.kts | 13 +++++++ .../com/pureswift/swiftui/desktop/Main.kt | 29 +++++++++++--- .../pureswift/swiftui/desktop/SwiftRuntime.kt | 29 ++++++++++++++ Sources/SwiftUIDesktopDemo/DesktopDemo.swift | 39 +++++++++++++++++++ 4 files changed, 105 insertions(+), 5 deletions(-) create mode 100644 Demo/desktop/src/jvmMain/kotlin/com/pureswift/swiftui/desktop/SwiftRuntime.kt create mode 100644 Sources/SwiftUIDesktopDemo/DesktopDemo.swift diff --git a/Demo/desktop/build.gradle.kts b/Demo/desktop/build.gradle.kts index 59656cd..8c9d86c 100644 --- a/Demo/desktop/build.gradle.kts +++ b/Demo/desktop/build.gradle.kts @@ -14,11 +14,24 @@ kotlin { implementation(compose.desktop.currentOs) implementation(compose.material3) } + jvmTest.dependencies { + implementation(kotlin("test")) + implementation(compose.desktop.uiTestJUnit4) + } } } +// Absolute path to the Swift-built dylib; the loader loads its sibling +// libSwiftJava.dylib first (JNI_OnLoad lives there). +val swiftLibrary = rootDir.resolve("../.build/arm64-apple-macosx/debug/libSwiftUIDesktopDemo.dylib").canonicalPath + +tasks.withType().configureEach { + systemProperty("swiftui.library", swiftLibrary) +} + compose.desktop { application { mainClass = "com.pureswift.swiftui.desktop.MainKt" + jvmArgs += "-Dswiftui.library=$swiftLibrary" } } diff --git a/Demo/desktop/src/jvmMain/kotlin/com/pureswift/swiftui/desktop/Main.kt b/Demo/desktop/src/jvmMain/kotlin/com/pureswift/swiftui/desktop/Main.kt index 00c04dc..ad550c1 100644 --- a/Demo/desktop/src/jvmMain/kotlin/com/pureswift/swiftui/desktop/Main.kt +++ b/Demo/desktop/src/jvmMain/kotlin/com/pureswift/swiftui/desktop/Main.kt @@ -64,16 +64,35 @@ object Fixtures { ) } -fun main() = application { - Window(onCloseRequest = ::exitApplication, title = "AndroidSwiftUI desktop rig") { - MaterialTheme { - Surface { - RigContent() +fun main() { + val live = SwiftRuntime.load() + application { + Window(onCloseRequest = ::exitApplication, title = "AndroidSwiftUI desktop rig") { + MaterialTheme { + Surface { + if (live) { + LiveContent() + } else { + RigContent() + } + } } } } } +/// Live mode: the Swift dylib evaluates its root view and drives the store. +@Composable +private fun LiveContent() { + val store = remember { + TreeStore().also { + com.pureswift.swiftui.SwiftBridge.sink = com.pureswift.swiftui.SwiftCallbackSink() + SwiftRuntime().start(it) + } + } + store.root?.let { Render(it) } +} + @Composable private fun RigContent() { val store = remember { TreeStore() } diff --git a/Demo/desktop/src/jvmMain/kotlin/com/pureswift/swiftui/desktop/SwiftRuntime.kt b/Demo/desktop/src/jvmMain/kotlin/com/pureswift/swiftui/desktop/SwiftRuntime.kt new file mode 100644 index 0000000..9b3904b --- /dev/null +++ b/Demo/desktop/src/jvmMain/kotlin/com/pureswift/swiftui/desktop/SwiftRuntime.kt @@ -0,0 +1,29 @@ +package com.pureswift.swiftui.desktop + +import com.pureswift.swiftui.TreeStore + +// Entry point into the Swift dylib on the desktop JVM. Loading the library +// fires swift-java's JNI_OnLoad; `start` hands Swift the tree store, and the +// Swift side evaluates its root view and drives the store from then on. +class SwiftRuntime { + + external fun start(store: TreeStore) + + companion object { + + /// Loads the Swift libraries from `-Dswiftui.library=`. + /// swift-java's `JNI_OnLoad` lives in libSwiftJava, and the JVM only + /// fires it for explicitly loaded libraries — so the runtime library + /// (sibling `libSwiftJava.dylib`) loads first, then the app library. + /// Returns false (rig falls back to fixtures) when not configured. + fun load(): Boolean { + val path = System.getProperty("swiftui.library") ?: return false + val runtime = java.io.File(java.io.File(path).parentFile, "libSwiftJava.dylib") + if (runtime.exists()) { + System.load(runtime.absolutePath) + } + System.load(path) + return true + } + } +} diff --git a/Sources/SwiftUIDesktopDemo/DesktopDemo.swift b/Sources/SwiftUIDesktopDemo/DesktopDemo.swift new file mode 100644 index 0000000..e43df5f --- /dev/null +++ b/Sources/SwiftUIDesktopDemo/DesktopDemo.swift @@ -0,0 +1,39 @@ +// +// DesktopDemo.swift +// SwiftUIDesktopDemo +// +// The desktop rig's Swift entry: a counter view evaluated by the core and +// pushed through the bridge into the rig's Compose window. +// + +import AndroidSwiftUICore +import AndroidSwiftUIBridge +import SwiftJava + +struct CounterDemo: View { + + @State private var count = 0 + + var body: some View { + VStack(spacing: 12) { + Text("Count: \(count)") + Button("Increment") { count += 1 } + Toggle("Feature flag", isOn: .constant(true)) + } + } +} + +@JavaClass("com.pureswift.swiftui.desktop.SwiftRuntime") +open class SwiftRuntime: JavaObject { +} + +@JavaImplementation("com.pureswift.swiftui.desktop.SwiftRuntime") +extension SwiftRuntime { + + @JavaMethod + func start(_ store: TreeStore?) { + guard let store else { return } + let runtime = BridgeRuntime(root: CounterDemo(), store: store) + runtime.start() + } +} From 494c9400f00cc8795750b10edc96bd9d701d9583 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Wed, 22 Jul 2026 20:39:46 -0400 Subject: [PATCH 5/7] Test the full bridge round trip on the desktop JVM Native Swift evaluates the counter, JNI materializes it, Compose renders it, clicks dispatch back into Swift state, and the UI shows each new count. --- .../pureswift/swiftui/desktop/BridgeTests.kt | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 Demo/desktop/src/jvmTest/kotlin/com/pureswift/swiftui/desktop/BridgeTests.kt diff --git a/Demo/desktop/src/jvmTest/kotlin/com/pureswift/swiftui/desktop/BridgeTests.kt b/Demo/desktop/src/jvmTest/kotlin/com/pureswift/swiftui/desktop/BridgeTests.kt new file mode 100644 index 0000000..372a567 --- /dev/null +++ b/Demo/desktop/src/jvmTest/kotlin/com/pureswift/swiftui/desktop/BridgeTests.kt @@ -0,0 +1,47 @@ +package com.pureswift.swiftui.desktop + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.pureswift.swiftui.Render +import com.pureswift.swiftui.SwiftBridge +import com.pureswift.swiftui.SwiftCallbackSink +import com.pureswift.swiftui.TreeStore +import org.junit.Assume.assumeTrue +import org.junit.Rule +import org.junit.Test + +/// The end-to-end bridge test: native Swift evaluates the view tree, JNI +/// materializes it into Kotlin nodes, Compose renders it, a click dispatches +/// back into Swift, Swift re-evaluates, and the UI shows the new state. +/// Runs headless on the host JVM — no emulator, no window. +class BridgeTests { + + @get:Rule + val compose = createComposeRule() + + @Test + fun counterRoundTripAcrossTheBridge() { + assumeTrue("swiftui.library not set — bridge test skipped", SwiftRuntime.load()) + + val store = TreeStore() + SwiftBridge.sink = SwiftCallbackSink() + SwiftRuntime().start(store) + + compose.setContent { + store.root?.let { Render(it) } + } + + // initial tree evaluated in Swift + compose.onNodeWithText("Count: 0").assertIsDisplayed() + + // Compose click → JNI → Swift @State write → re-evaluate → new tree + compose.onNodeWithText("Increment").performClick() + compose.onNodeWithText("Count: 1").assertIsDisplayed() + + // and again, proving the callback registry survives re-registration + compose.onNodeWithText("Increment").performClick() + compose.onNodeWithText("Count: 2").assertIsDisplayed() + } +} From 817e3a65bdf940861af3da2e9a6d035d934e06bc Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Wed, 22 Jul 2026 20:39:46 -0400 Subject: [PATCH 6/7] Host the Compose renderer in the Android activity SwiftUIHostView wraps the interpreter in a ComposeView; AndroidSwiftUIApp.run starts the bridge runtime against its store and installs it as the content view. The umbrella re-exports the core so app code needs a single import. --- Demo/app/build.gradle.kts | 1 + .../com/pureswift/swiftui/SwiftUIHostView.kt | 32 ++++++++++++++ Sources/AndroidSwiftUI/AndroidSwiftUI.swift | 10 +++++ Sources/AndroidSwiftUI/SwiftUIHostView.swift | 44 +++++++++++++++++++ 4 files changed, 87 insertions(+) create mode 100644 Demo/swiftui/src/androidMain/kotlin/com/pureswift/swiftui/SwiftUIHostView.kt create mode 100644 Sources/AndroidSwiftUI/SwiftUIHostView.swift diff --git a/Demo/app/build.gradle.kts b/Demo/app/build.gradle.kts index 8707008..50bbed5 100644 --- a/Demo/app/build.gradle.kts +++ b/Demo/app/build.gradle.kts @@ -67,6 +67,7 @@ android { } dependencies { + implementation(project(":swiftui")) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) diff --git a/Demo/swiftui/src/androidMain/kotlin/com/pureswift/swiftui/SwiftUIHostView.kt b/Demo/swiftui/src/androidMain/kotlin/com/pureswift/swiftui/SwiftUIHostView.kt new file mode 100644 index 0000000..c83588a --- /dev/null +++ b/Demo/swiftui/src/androidMain/kotlin/com/pureswift/swiftui/SwiftUIHostView.kt @@ -0,0 +1,32 @@ +package com.pureswift.swiftui + +import android.content.Context +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.platform.ComposeView + +// The Android host: one Compose island rendering the whole Swift-evaluated +// tree. Swift constructs this, hands its store to the bridge runtime, and +// installs it as the activity's content view. +class SwiftUIHostView(context: Context) : FrameLayout(context) { + + val store = TreeStore() + + init { + SwiftBridge.sink = SwiftCallbackSink() + val composeView = ComposeView(context) + composeView.setContent { + MaterialTheme { + Surface { + store.root?.let { Render(it) } + } + } + } + addView( + composeView, + ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + ) + } +} diff --git a/Sources/AndroidSwiftUI/AndroidSwiftUI.swift b/Sources/AndroidSwiftUI/AndroidSwiftUI.swift index 76da3a9..db51094 100644 --- a/Sources/AndroidSwiftUI/AndroidSwiftUI.swift +++ b/Sources/AndroidSwiftUI/AndroidSwiftUI.swift @@ -1,2 +1,12 @@ import Foundation import AndroidKit + +// The public SwiftUI API surface comes from the platform-neutral core. +@_exported import AndroidSwiftUICore + +/// Logs a message to logcat under the "SwiftUI" tag, so app code doesn't need +/// AndroidKit (whose `AndroidView.View` would collide with the core's `View`). +public func AndroidSwiftUILog(_ message: String) { + let log = try! JavaClass() + _ = log.v("SwiftUI", message) +} diff --git a/Sources/AndroidSwiftUI/SwiftUIHostView.swift b/Sources/AndroidSwiftUI/SwiftUIHostView.swift new file mode 100644 index 0000000..8ff8730 --- /dev/null +++ b/Sources/AndroidSwiftUI/SwiftUIHostView.swift @@ -0,0 +1,44 @@ +// +// SwiftUIHostView.swift +// AndroidSwiftUI +// +// Created by Alsey Coleman Miller on 7/22/26. +// + +import AndroidKit +import AndroidSwiftUIBridge +import AndroidSwiftUICore + +/// Binding for the Kotlin Compose host view. +@JavaClass("com.pureswift.swiftui.SwiftUIHostView") +open class SwiftUIHostView: AndroidView.View { + + @JavaMethod + @_nonoverride public convenience init(_ context: AndroidContent.Context?, environment: JNIEnvironment? = nil) + + @JavaMethod + open func getStore() -> AndroidSwiftUIBridge.TreeStore? +} + +/// Launches a SwiftUI root view as the activity's content. +public enum AndroidSwiftUIApp { + + // retains the runtime for the activity's lifetime + private static var runtime: BridgeRuntime? + + public static func run(_ root: any AndroidSwiftUICore.View) { + guard let activity = MainActivity.shared else { + assertionFailure("MainActivity not created yet") + return + } + let host = SwiftUIHostView(activity as AndroidContent.Context) + guard let store = host.getStore() else { + assertionFailure("host view has no tree store") + return + } + let runtime = BridgeRuntime(root: root, store: store) + Self.runtime = runtime + runtime.start() + activity.setRootView(host) + } +} From f186337c3dd292b0894e0239b2832da6b3de7bb7 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Wed, 22 Jul 2026 20:39:46 -0400 Subject: [PATCH 7/7] Run a counter through the Compose renderer in the demo --- Demo/App.swiftpm/Sources/App.swift | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/Demo/App.swiftpm/Sources/App.swift b/Demo/App.swiftpm/Sources/App.swift index 34ec108..b999ac3 100644 --- a/Demo/App.swiftpm/Sources/App.swift +++ b/Demo/App.swiftpm/Sources/App.swift @@ -1,20 +1,32 @@ #if canImport(AndroidSwiftUI) import AndroidSwiftUI -import AndroidKit #else import SwiftUI #endif // The gallery screens are restored alongside the view types they exercise as the -// Compose-backed renderer is built up; until then this is a bare launch point. +// Compose-backed renderer is built up. + +struct ContentView: View { + + @State private var count = 0 + + var body: some View { + VStack(spacing: 12) { + Text("Count: \(count)") + Button("Increment") { count += 1 } + Toggle("Feature flag", isOn: .constant(true)) + } + } +} #if canImport(AndroidSwiftUI) /// App launch point, called from `MainActivity`. @_silgen_name("AndroidSwiftUIMain") func AndroidSwiftUIMain() { - let log = try! JavaClass() - _ = log.v("DemoApp", "Starting SwiftUI App") + AndroidSwiftUILog("Starting SwiftUI App") + AndroidSwiftUIApp.run(ContentView()) } #else @@ -24,7 +36,7 @@ struct DemoApp: App { var body: some Scene { WindowGroup { - Text("Demo") + ContentView() } } }