Skip to content

Commit cd4bd7e

Browse files
authored
Merge pull request #21 from PureSwift/feature/bridge
Bridge the evaluation core to the Compose interpreter
2 parents 9c223fd + f186337 commit cd4bd7e

18 files changed

Lines changed: 579 additions & 11 deletions

File tree

Demo/App.swiftpm/Sources/App.swift

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,32 @@
11
#if canImport(AndroidSwiftUI)
22
import AndroidSwiftUI
3-
import AndroidKit
43
#else
54
import SwiftUI
65
#endif
76

87
// The gallery screens are restored alongside the view types they exercise as the
9-
// Compose-backed renderer is built up; until then this is a bare launch point.
8+
// Compose-backed renderer is built up.
9+
10+
struct ContentView: View {
11+
12+
@State private var count = 0
13+
14+
var body: some View {
15+
VStack(spacing: 12) {
16+
Text("Count: \(count)")
17+
Button("Increment") { count += 1 }
18+
Toggle("Feature flag", isOn: .constant(true))
19+
}
20+
}
21+
}
1022

1123
#if canImport(AndroidSwiftUI)
1224

1325
/// App launch point, called from `MainActivity`.
1426
@_silgen_name("AndroidSwiftUIMain")
1527
func AndroidSwiftUIMain() {
16-
let log = try! JavaClass<AndroidUtil.Log>()
17-
_ = log.v("DemoApp", "Starting SwiftUI App")
28+
AndroidSwiftUILog("Starting SwiftUI App")
29+
AndroidSwiftUIApp.run(ContentView())
1830
}
1931

2032
#else
@@ -24,7 +36,7 @@ struct DemoApp: App {
2436

2537
var body: some Scene {
2638
WindowGroup {
27-
Text("Demo")
39+
ContentView()
2840
}
2941
}
3042
}

Demo/app/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ android {
6767
}
6868

6969
dependencies {
70+
implementation(project(":swiftui"))
7071

7172
implementation(libs.androidx.core.ktx)
7273
implementation(libs.androidx.lifecycle.runtime.ktx)

Demo/desktop/build.gradle.kts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,24 @@ kotlin {
1414
implementation(compose.desktop.currentOs)
1515
implementation(compose.material3)
1616
}
17+
jvmTest.dependencies {
18+
implementation(kotlin("test"))
19+
implementation(compose.desktop.uiTestJUnit4)
20+
}
1721
}
1822
}
1923

24+
// Absolute path to the Swift-built dylib; the loader loads its sibling
25+
// libSwiftJava.dylib first (JNI_OnLoad lives there).
26+
val swiftLibrary = rootDir.resolve("../.build/arm64-apple-macosx/debug/libSwiftUIDesktopDemo.dylib").canonicalPath
27+
28+
tasks.withType<Test>().configureEach {
29+
systemProperty("swiftui.library", swiftLibrary)
30+
}
31+
2032
compose.desktop {
2133
application {
2234
mainClass = "com.pureswift.swiftui.desktop.MainKt"
35+
jvmArgs += "-Dswiftui.library=$swiftLibrary"
2336
}
2437
}

Demo/desktop/src/jvmMain/kotlin/com/pureswift/swiftui/desktop/Main.kt

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,16 +64,35 @@ object Fixtures {
6464
)
6565
}
6666

67-
fun main() = application {
68-
Window(onCloseRequest = ::exitApplication, title = "AndroidSwiftUI desktop rig") {
69-
MaterialTheme {
70-
Surface {
71-
RigContent()
67+
fun main() {
68+
val live = SwiftRuntime.load()
69+
application {
70+
Window(onCloseRequest = ::exitApplication, title = "AndroidSwiftUI desktop rig") {
71+
MaterialTheme {
72+
Surface {
73+
if (live) {
74+
LiveContent()
75+
} else {
76+
RigContent()
77+
}
78+
}
7279
}
7380
}
7481
}
7582
}
7683

84+
/// Live mode: the Swift dylib evaluates its root view and drives the store.
85+
@Composable
86+
private fun LiveContent() {
87+
val store = remember {
88+
TreeStore().also {
89+
com.pureswift.swiftui.SwiftBridge.sink = com.pureswift.swiftui.SwiftCallbackSink()
90+
SwiftRuntime().start(it)
91+
}
92+
}
93+
store.root?.let { Render(it) }
94+
}
95+
7796
@Composable
7897
private fun RigContent() {
7998
val store = remember { TreeStore() }
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.pureswift.swiftui.desktop
2+
3+
import com.pureswift.swiftui.TreeStore
4+
5+
// Entry point into the Swift dylib on the desktop JVM. Loading the library
6+
// fires swift-java's JNI_OnLoad; `start` hands Swift the tree store, and the
7+
// Swift side evaluates its root view and drives the store from then on.
8+
class SwiftRuntime {
9+
10+
external fun start(store: TreeStore)
11+
12+
companion object {
13+
14+
/// Loads the Swift libraries from `-Dswiftui.library=<absolute path>`.
15+
/// swift-java's `JNI_OnLoad` lives in libSwiftJava, and the JVM only
16+
/// fires it for explicitly loaded libraries — so the runtime library
17+
/// (sibling `libSwiftJava.dylib`) loads first, then the app library.
18+
/// Returns false (rig falls back to fixtures) when not configured.
19+
fun load(): Boolean {
20+
val path = System.getProperty("swiftui.library") ?: return false
21+
val runtime = java.io.File(java.io.File(path).parentFile, "libSwiftJava.dylib")
22+
if (runtime.exists()) {
23+
System.load(runtime.absolutePath)
24+
}
25+
System.load(path)
26+
return true
27+
}
28+
}
29+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package com.pureswift.swiftui.desktop
2+
3+
import androidx.compose.ui.test.assertIsDisplayed
4+
import androidx.compose.ui.test.junit4.createComposeRule
5+
import androidx.compose.ui.test.onNodeWithText
6+
import androidx.compose.ui.test.performClick
7+
import com.pureswift.swiftui.Render
8+
import com.pureswift.swiftui.SwiftBridge
9+
import com.pureswift.swiftui.SwiftCallbackSink
10+
import com.pureswift.swiftui.TreeStore
11+
import org.junit.Assume.assumeTrue
12+
import org.junit.Rule
13+
import org.junit.Test
14+
15+
/// The end-to-end bridge test: native Swift evaluates the view tree, JNI
16+
/// materializes it into Kotlin nodes, Compose renders it, a click dispatches
17+
/// back into Swift, Swift re-evaluates, and the UI shows the new state.
18+
/// Runs headless on the host JVM — no emulator, no window.
19+
class BridgeTests {
20+
21+
@get:Rule
22+
val compose = createComposeRule()
23+
24+
@Test
25+
fun counterRoundTripAcrossTheBridge() {
26+
assumeTrue("swiftui.library not set — bridge test skipped", SwiftRuntime.load())
27+
28+
val store = TreeStore()
29+
SwiftBridge.sink = SwiftCallbackSink()
30+
SwiftRuntime().start(store)
31+
32+
compose.setContent {
33+
store.root?.let { Render(it) }
34+
}
35+
36+
// initial tree evaluated in Swift
37+
compose.onNodeWithText("Count: 0").assertIsDisplayed()
38+
39+
// Compose click → JNI → Swift @State write → re-evaluate → new tree
40+
compose.onNodeWithText("Increment").performClick()
41+
compose.onNodeWithText("Count: 1").assertIsDisplayed()
42+
43+
// and again, proving the callback registry survives re-registration
44+
compose.onNodeWithText("Increment").performClick()
45+
compose.onNodeWithText("Count: 2").assertIsDisplayed()
46+
}
47+
}

Demo/swiftui/build.gradle.kts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,15 @@ kotlin {
2828
implementation(compose.material3)
2929
implementation(libs.kotlinx.serialization.json)
3030
}
31+
// `external fun` is JVM-only; both targets are JVM, so the bridge's
32+
// Swift-implemented classes live in a source set they share.
33+
val jvmShared by creating {
34+
dependsOn(commonMain.get())
35+
}
36+
androidMain.get().dependsOn(jvmShared)
37+
val desktopMain by getting {
38+
dependsOn(jvmShared)
39+
}
3140
val desktopTest by getting {
3241
dependencies {
3342
implementation(kotlin("test"))
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package com.pureswift.swiftui
2+
3+
import android.content.Context
4+
import android.view.ViewGroup
5+
import android.widget.FrameLayout
6+
import androidx.compose.material3.MaterialTheme
7+
import androidx.compose.material3.Surface
8+
import androidx.compose.ui.platform.ComposeView
9+
10+
// The Android host: one Compose island rendering the whole Swift-evaluated
11+
// tree. Swift constructs this, hands its store to the bridge runtime, and
12+
// installs it as the activity's content view.
13+
class SwiftUIHostView(context: Context) : FrameLayout(context) {
14+
15+
val store = TreeStore()
16+
17+
init {
18+
SwiftBridge.sink = SwiftCallbackSink()
19+
val composeView = ComposeView(context)
20+
composeView.setContent {
21+
MaterialTheme {
22+
Surface {
23+
store.root?.let { Render(it) }
24+
}
25+
}
26+
}
27+
addView(
28+
composeView,
29+
ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
30+
)
31+
}
32+
}

Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/ViewNode.kt

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ package com.pureswift.swiftui
22

33
import androidx.compose.runtime.Immutable
44
import kotlinx.serialization.Serializable
5+
import kotlinx.serialization.json.Json
56
import kotlinx.serialization.json.JsonObject
67
import kotlinx.serialization.json.JsonPrimitive
78
import kotlinx.serialization.json.booleanOrNull
89
import kotlinx.serialization.json.doubleOrNull
10+
import kotlinx.serialization.json.jsonObject
911
import kotlinx.serialization.json.longOrNull
1012

1113
/// One entry in a node's ordered modifier chain. Order is significant: the
@@ -39,6 +41,33 @@ data class ViewNode(
3941
val itemProviderId: Long? = null,
4042
) {
4143

44+
/// Bridge constructor: the Swift materializer builds nodes through this,
45+
/// crossing JNI with flat arrays (one call per node, arrays as single
46+
/// arguments). Prop and modifier-arg values arrive as JSON literals
47+
/// ("\"text\"", "42", "true"), keeping the typed JsonObject model without
48+
/// per-field JNI calls. Negative count/provider mean "absent".
49+
constructor(
50+
type: String,
51+
id: String,
52+
propKeys: Array<String>,
53+
propValues: Array<String>,
54+
modifierKinds: Array<String>,
55+
modifierArgs: Array<String>,
56+
children: Array<ViewNode>,
57+
count: Int,
58+
itemProviderId: Long,
59+
) : this(
60+
type = type,
61+
id = id,
62+
props = JsonObject(propKeys.indices.associate { propKeys[it] to Json.parseToJsonElement(propValues[it]) }),
63+
modifiers = modifierKinds.indices.map {
64+
ModifierNode(modifierKinds[it], Json.parseToJsonElement(modifierArgs[it]).jsonObject)
65+
},
66+
children = children.toList(),
67+
count = if (count >= 0) count else null,
68+
itemProviderId = if (itemProviderId >= 0) itemProviderId else null,
69+
)
70+
4271
// Typed prop accessors used by the interpreter.
4372

4473
fun string(key: String): String? = (props[key] as? JsonPrimitive)?.content
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.pureswift.swiftui
2+
3+
// The entire Kotlin→Swift bridge surface: five externals dispatching event
4+
// callback ids into the Swift registry. The JNI symbol for each derives from
5+
// the SWIFT @JavaImplementation signature — these declarations and the Swift
6+
// counterparts in SwiftCallbackSink.swift must stay exactly in sync, and this
7+
// class must never grow per-view methods.
8+
class SwiftCallbackSink : CallbackSink {
9+
10+
external override fun invokeVoid(id: Long)
11+
12+
external override fun invokeBool(id: Long, value: Boolean)
13+
14+
external override fun invokeDouble(id: Long, value: Double)
15+
16+
external override fun invokeInt(id: Long, value: Int)
17+
18+
external override fun invokeString(id: Long, value: String)
19+
}

0 commit comments

Comments
 (0)