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
Original file line number Diff line number Diff line change
Expand Up @@ -27,31 +27,64 @@
// 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<Content: View>: 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()
}

public typealias Body = Never
}

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() }
}
}

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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
11 changes: 8 additions & 3 deletions Demo/App.swiftpm/Sources/RepresentablePlaygrounds.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading