Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions Demo/App.swiftpm/Sources/App.swift
Original file line number Diff line number Diff line change
@@ -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<AndroidUtil.Log>()
_ = log.v("DemoApp", "Starting SwiftUI App")
AndroidSwiftUILog("Starting SwiftUI App")
AndroidSwiftUIApp.run(ContentView())
}

#else
Expand All @@ -24,7 +36,7 @@ struct DemoApp: App {

var body: some Scene {
WindowGroup {
Text("Demo")
ContentView()
}
}
}
Expand Down
1 change: 1 addition & 0 deletions Demo/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ android {
}

dependencies {
implementation(project(":swiftui"))

implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
Expand Down
13 changes: 13 additions & 0 deletions Demo/desktop/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Test>().configureEach {
systemProperty("swiftui.library", swiftLibrary)
}

compose.desktop {
application {
mainClass = "com.pureswift.swiftui.desktop.MainKt"
jvmArgs += "-Dswiftui.library=$swiftLibrary"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand Down
Original file line number Diff line number Diff line change
@@ -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=<absolute path>`.
/// 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
}
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
9 changes: 9 additions & 0 deletions Demo/swiftui/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String>,
propValues: Array<String>,
modifierKinds: Array<String>,
modifierArgs: Array<String>,
children: Array<ViewNode>,
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
46 changes: 45 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
]
)
]
)
10 changes: 10 additions & 0 deletions Sources/AndroidSwiftUI/AndroidSwiftUI.swift
Original file line number Diff line number Diff line change
@@ -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<AndroidUtil.Log>()
_ = log.v("SwiftUI", message)
}
Loading
Loading