From 757eb42049823e407db50dbbbca362d352e5df85 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 14:15:47 -0400 Subject: [PATCH 1/5] Add ComposableView actions for callbacks back into Swift --- .../Primitives/ComposableView.swift | 39 +++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/ComposableView.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/ComposableView.swift index 1cca2ea..3f7b239 100644 --- a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/ComposableView.swift +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/ComposableView.swift @@ -27,15 +27,43 @@ // renders a visible diagnostic rather than failing silently. // +/// A callback a custom composable can invoke to send an event back to Swift — +/// the counterpart to a `UIViewRepresentable`'s coordinator. The associated +/// value's type matches the fixed bridge dispatch surface. +public enum ComposableAction { + case void(() -> Void) + case bool((Bool) -> Void) + case double((Double) -> Void) + case int((Int) -> Void) + case string((String) -> Void) + + internal func register(in callbacks: CallbackRegistry) -> Int64 { + switch self { + case .void(let action): return callbacks.register(.void(action)) + case .bool(let action): return callbacks.register(.bool(action)) + case .double(let action): return callbacks.register(.double(action)) + case .int(let action): return callbacks.register(.int(action)) + case .string(let action): return callbacks.register(.string(action)) + } + } +} + public struct ComposableView: View { internal let name: String internal let props: [String: PropValue] + internal let actions: [String: ComposableAction] internal let content: Content - public init(_ name: String, props: [String: PropValue] = [:], @ViewBuilder content: () -> Content) { + public init( + _ name: String, + props: [String: PropValue] = [:], + actions: [String: ComposableAction] = [:], + @ViewBuilder content: () -> Content + ) { self.name = name self.props = props + self.actions = actions self.content = content() } @@ -43,8 +71,8 @@ public struct ComposableView: View { } public extension ComposableView where Content == EmptyView { - init(_ name: String, props: [String: PropValue] = [:]) { - self.init(name, props: props) { EmptyView() } + init(_ name: String, props: [String: PropValue] = [:], actions: [String: ComposableAction] = [:]) { + self.init(name, props: props, actions: actions) { EmptyView() } } } @@ -52,6 +80,11 @@ extension ComposableView: PrimitiveView { public func _render(in context: ResolveContext) -> RenderNode { var props = self.props props["name"] = .string(name) // reserved: identifies the registered factory + // Each action registers a callback; its id crosses as a prop the factory + // reads back as a typed lambda. + for (key, action) in actions { + props[key] = .int(Int(action.register(in: context.callbacks))) + } return RenderNode( type: "Composable", id: context.path, From 795ad88e21da5608bf6e6c665827e644fcc654ef Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 14:15:47 -0400 Subject: [PATCH 2/5] Test ComposableView action dispatch --- .../AndroidSwiftUICoreTests/EvaluatorTests.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift index f620ca1..b99f7de 100644 --- a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift +++ b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift @@ -291,6 +291,20 @@ struct GraphicsTests { #expect(firstTextString(node.children.first ?? node) == "inside") } + @Test("ComposableView actions register callbacks the factory can invoke") + func composableViewActions() { + var received = 0.0 + let host = ViewHost( + ComposableView("RatingBar", actions: ["onRatingChanged": .double { received = $0 }]) + ) + let node = host.evaluate() + guard case .int(let id)? = node.props["onRatingChanged"] else { + Issue.record("missing action id"); return + } + host.callbacks.invokeDouble(Int64(id), 4.5) + #expect(received == 4.5) + } + @Test("Overlay emits base and overlay children with alignment") func overlay() { let node = ViewHost(Color.blue.overlay(alignment: .bottomTrailing) { Text("badge") }).evaluate() From f11683c0e719ec8ddfa120003ddade400816d1af Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 14:15:47 -0400 Subject: [PATCH 3/5] Expose typed action lambdas on registry props --- .../com/pureswift/swiftui/ComposableRegistry.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/ComposableRegistry.kt b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/ComposableRegistry.kt index 4700d51..57992bf 100644 --- a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/ComposableRegistry.kt +++ b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/ComposableRegistry.kt @@ -20,6 +20,20 @@ class Props internal constructor(private val json: JsonObject) { fun bool(key: String): Boolean? = (json[key] as? JsonPrimitive)?.booleanOrNull /// A color passed from Swift as `PropValue.color(_:)` (an ARGB int). fun color(key: String): Color? = (json[key] as? JsonPrimitive)?.longOrNull?.let { Color(it.toInt()) } + + // Actions: a `ComposableView(actions:)` entry arrives as a callback id; each + // accessor returns a typed lambda that dispatches back to Swift, or null if + // the key is absent. The factory never sees the id or the bridge. + fun voidAction(key: String): (() -> Unit)? = + (json[key] as? JsonPrimitive)?.longOrNull?.let { id -> { SwiftBridge.sink.invokeVoid(id) } } + fun boolAction(key: String): ((Boolean) -> Unit)? = + (json[key] as? JsonPrimitive)?.longOrNull?.let { id -> { v -> SwiftBridge.sink.invokeBool(id, v) } } + fun doubleAction(key: String): ((Double) -> Unit)? = + (json[key] as? JsonPrimitive)?.longOrNull?.let { id -> { v -> SwiftBridge.sink.invokeDouble(id, v) } } + fun intAction(key: String): ((Int) -> Unit)? = + (json[key] as? JsonPrimitive)?.longOrNull?.let { id -> { v -> SwiftBridge.sink.invokeInt(id, v) } } + fun stringAction(key: String): ((String) -> Unit)? = + (json[key] as? JsonPrimitive)?.longOrNull?.let { id -> { v -> SwiftBridge.sink.invokeString(id, v) } } } /// The library's single extension point: Kotlin registers named composables; From 61ed15a4a66fc38c0b355b4f5b026a0ea0870511 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 14:15:47 -0400 Subject: [PATCH 4/5] Make the demo RatingBar interactive and report changes --- .../java/com/pureswift/swiftandroid/DemoComposables.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Demo/app/src/main/java/com/pureswift/swiftandroid/DemoComposables.kt b/Demo/app/src/main/java/com/pureswift/swiftandroid/DemoComposables.kt index 527ce6c..3bfb34d 100644 --- a/Demo/app/src/main/java/com/pureswift/swiftandroid/DemoComposables.kt +++ b/Demo/app/src/main/java/com/pureswift/swiftandroid/DemoComposables.kt @@ -23,17 +23,22 @@ fun registerDemoComposables() { ComposableRegistry.register("RatingBar") { props, _ -> val rating = props.float("rating") ?: 0f val max = props.int("max") ?: 5 + val onChanged = props.doubleAction("onRatingChanged") AndroidView( factory = { context -> RatingBar(context).apply { numStars = max stepSize = 0.5f - setIsIndicator(true) + // dragging the stars sends the new rating back to Swift; + // `fromUser` filters out our own programmatic updates + setOnRatingBarChangeListener { _, value, fromUser -> + if (fromUser) onChanged?.invoke(value.toDouble()) + } } }, update = { bar -> bar.numStars = max - bar.rating = rating + if (bar.rating != rating) bar.rating = rating }, ) } From 6d4c443a4e93d58ee9a8319f730da0964b2f480b Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 14:15:48 -0400 Subject: [PATCH 5/5] Show two-way RatingBar binding in the playground --- .../Sources/RepresentablePlaygrounds.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Demo/App.swiftpm/Sources/RepresentablePlaygrounds.swift b/Demo/App.swiftpm/Sources/RepresentablePlaygrounds.swift index adf6030..8cacb1d 100644 --- a/Demo/App.swiftpm/Sources/RepresentablePlaygrounds.swift +++ b/Demo/App.swiftpm/Sources/RepresentablePlaygrounds.swift @@ -9,15 +9,20 @@ struct RepresentablePlayground: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { - Example("Native Android RatingBar") { + Example("Native RatingBar (two-way)") { VStack(alignment: .leading, spacing: 8) { - ComposableView("RatingBar", props: ["rating": .double(stars), "max": 5]) - .frame(height: 60) + ComposableView( + "RatingBar", + props: ["rating": .double(stars), "max": 5], + actions: ["onRatingChanged": .double { stars = $0 }] + ) + .frame(height: 60) HStack { Button("−½") { stars = max(0, stars - 0.5) } Button("+½") { stars = min(5, stars + 0.5) } Text("\(stars) / 5") } + Text("Drag the stars or use the buttons — both drive the same state.") } } Example("Compose function with SwiftUI children") {