Skip to content

Commit 88094fe

Browse files
authored
Merge pull request #40 from PureSwift/feature/composable-actions
2 parents 509221c + 6d4c443 commit 88094fe

5 files changed

Lines changed: 79 additions & 8 deletions

File tree

AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/ComposableView.swift

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,31 +27,64 @@
2727
// renders a visible diagnostic rather than failing silently.
2828
//
2929

30+
/// A callback a custom composable can invoke to send an event back to Swift —
31+
/// the counterpart to a `UIViewRepresentable`'s coordinator. The associated
32+
/// value's type matches the fixed bridge dispatch surface.
33+
public enum ComposableAction {
34+
case void(() -> Void)
35+
case bool((Bool) -> Void)
36+
case double((Double) -> Void)
37+
case int((Int) -> Void)
38+
case string((String) -> Void)
39+
40+
internal func register(in callbacks: CallbackRegistry) -> Int64 {
41+
switch self {
42+
case .void(let action): return callbacks.register(.void(action))
43+
case .bool(let action): return callbacks.register(.bool(action))
44+
case .double(let action): return callbacks.register(.double(action))
45+
case .int(let action): return callbacks.register(.int(action))
46+
case .string(let action): return callbacks.register(.string(action))
47+
}
48+
}
49+
}
50+
3051
public struct ComposableView<Content: View>: View {
3152

3253
internal let name: String
3354
internal let props: [String: PropValue]
55+
internal let actions: [String: ComposableAction]
3456
internal let content: Content
3557

36-
public init(_ name: String, props: [String: PropValue] = [:], @ViewBuilder content: () -> Content) {
58+
public init(
59+
_ name: String,
60+
props: [String: PropValue] = [:],
61+
actions: [String: ComposableAction] = [:],
62+
@ViewBuilder content: () -> Content
63+
) {
3764
self.name = name
3865
self.props = props
66+
self.actions = actions
3967
self.content = content()
4068
}
4169

4270
public typealias Body = Never
4371
}
4472

4573
public extension ComposableView where Content == EmptyView {
46-
init(_ name: String, props: [String: PropValue] = [:]) {
47-
self.init(name, props: props) { EmptyView() }
74+
init(_ name: String, props: [String: PropValue] = [:], actions: [String: ComposableAction] = [:]) {
75+
self.init(name, props: props, actions: actions) { EmptyView() }
4876
}
4977
}
5078

5179
extension ComposableView: PrimitiveView {
5280
public func _render(in context: ResolveContext) -> RenderNode {
5381
var props = self.props
5482
props["name"] = .string(name) // reserved: identifies the registered factory
83+
// Each action registers a callback; its id crosses as a prop the factory
84+
// reads back as a typed lambda.
85+
for (key, action) in actions {
86+
props[key] = .int(Int(action.register(in: context.callbacks)))
87+
}
5588
return RenderNode(
5689
type: "Composable",
5790
id: context.path,

AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,20 @@ struct GraphicsTests {
317317
#expect(firstTextString(node.children.first ?? node) == "inside")
318318
}
319319

320+
@Test("ComposableView actions register callbacks the factory can invoke")
321+
func composableViewActions() {
322+
var received = 0.0
323+
let host = ViewHost(
324+
ComposableView("RatingBar", actions: ["onRatingChanged": .double { received = $0 }])
325+
)
326+
let node = host.evaluate()
327+
guard case .int(let id)? = node.props["onRatingChanged"] else {
328+
Issue.record("missing action id"); return
329+
}
330+
host.callbacks.invokeDouble(Int64(id), 4.5)
331+
#expect(received == 4.5)
332+
}
333+
320334
@Test("Overlay emits base and overlay children with alignment")
321335
func overlay() {
322336
let node = ViewHost(Color.blue.overlay(alignment: .bottomTrailing) { Text("badge") }).evaluate()

Demo/App.swiftpm/Sources/RepresentablePlaygrounds.swift

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,20 @@ struct RepresentablePlayground: View {
99
var body: some View {
1010
ScrollView {
1111
VStack(alignment: .leading, spacing: 0) {
12-
Example("Native Android RatingBar") {
12+
Example("Native RatingBar (two-way)") {
1313
VStack(alignment: .leading, spacing: 8) {
14-
ComposableView("RatingBar", props: ["rating": .double(stars), "max": 5])
15-
.frame(height: 60)
14+
ComposableView(
15+
"RatingBar",
16+
props: ["rating": .double(stars), "max": 5],
17+
actions: ["onRatingChanged": .double { stars = $0 }]
18+
)
19+
.frame(height: 60)
1620
HStack {
1721
Button("−½") { stars = max(0, stars - 0.5) }
1822
Button("") { stars = min(5, stars + 0.5) }
1923
Text("\(stars) / 5")
2024
}
25+
Text("Drag the stars or use the buttons — both drive the same state.")
2126
}
2227
}
2328
Example("Compose function with SwiftUI children") {

Demo/app/src/main/java/com/pureswift/swiftandroid/DemoComposables.kt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,22 @@ fun registerDemoComposables() {
2323
ComposableRegistry.register("RatingBar") { props, _ ->
2424
val rating = props.float("rating") ?: 0f
2525
val max = props.int("max") ?: 5
26+
val onChanged = props.doubleAction("onRatingChanged")
2627
AndroidView(
2728
factory = { context ->
2829
RatingBar(context).apply {
2930
numStars = max
3031
stepSize = 0.5f
31-
setIsIndicator(true)
32+
// dragging the stars sends the new rating back to Swift;
33+
// `fromUser` filters out our own programmatic updates
34+
setOnRatingBarChangeListener { _, value, fromUser ->
35+
if (fromUser) onChanged?.invoke(value.toDouble())
36+
}
3237
}
3338
},
3439
update = { bar ->
3540
bar.numStars = max
36-
bar.rating = rating
41+
if (bar.rating != rating) bar.rating = rating
3742
},
3843
)
3944
}

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,20 @@ class Props internal constructor(private val json: JsonObject) {
2020
fun bool(key: String): Boolean? = (json[key] as? JsonPrimitive)?.booleanOrNull
2121
/// A color passed from Swift as `PropValue.color(_:)` (an ARGB int).
2222
fun color(key: String): Color? = (json[key] as? JsonPrimitive)?.longOrNull?.let { Color(it.toInt()) }
23+
24+
// Actions: a `ComposableView(actions:)` entry arrives as a callback id; each
25+
// accessor returns a typed lambda that dispatches back to Swift, or null if
26+
// the key is absent. The factory never sees the id or the bridge.
27+
fun voidAction(key: String): (() -> Unit)? =
28+
(json[key] as? JsonPrimitive)?.longOrNull?.let { id -> { SwiftBridge.sink.invokeVoid(id) } }
29+
fun boolAction(key: String): ((Boolean) -> Unit)? =
30+
(json[key] as? JsonPrimitive)?.longOrNull?.let { id -> { v -> SwiftBridge.sink.invokeBool(id, v) } }
31+
fun doubleAction(key: String): ((Double) -> Unit)? =
32+
(json[key] as? JsonPrimitive)?.longOrNull?.let { id -> { v -> SwiftBridge.sink.invokeDouble(id, v) } }
33+
fun intAction(key: String): ((Int) -> Unit)? =
34+
(json[key] as? JsonPrimitive)?.longOrNull?.let { id -> { v -> SwiftBridge.sink.invokeInt(id, v) } }
35+
fun stringAction(key: String): ((String) -> Unit)? =
36+
(json[key] as? JsonPrimitive)?.longOrNull?.let { id -> { v -> SwiftBridge.sink.invokeString(id, v) } }
2337
}
2438

2539
/// The library's single extension point: Kotlin registers named composables;

0 commit comments

Comments
 (0)