Skip to content

Commit 7eab81c

Browse files
authored
Merge pull request #39 from PureSwift/feature/composable-registry
2 parents f0ca64f + a77e0b8 commit 7eab81c

9 files changed

Lines changed: 251 additions & 16 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
//
2+
// ComposableView.swift
3+
// AndroidSwiftUICore
4+
//
5+
// The library's escape hatch for custom platform UI — the counterpart to
6+
// UIKit's `UIViewRepresentable`. Because a `@Composable` (or an Android
7+
// `View`) can never be authored in Swift, the seam is a *named registry*:
8+
// Kotlin registers `name → composable` factories once at startup, and this
9+
// view references one by name, forwarding typed props and an optional slot of
10+
// SwiftUI child content.
11+
//
12+
// Kotlin side:
13+
//
14+
// ComposableRegistry.register("RatingBar") { props, _ ->
15+
// AndroidView(factory = { RatingBar(it) }, update = {
16+
// it.rating = (props["rating"] as? JsonPrimitive)?.floatOrNull ?: 0f
17+
// })
18+
// }
19+
//
20+
// Swift side:
21+
//
22+
// ComposableView("RatingBar", props: ["rating": 3.5, "max": 5])
23+
//
24+
// A factory that wraps an Android-only `View` (maps, WebView, an ad SDK) uses
25+
// Compose's own `AndroidView` inside the registered composable — so arbitrary
26+
// custom Android views compose into a SwiftUI tree. An unregistered name
27+
// renders a visible diagnostic rather than failing silently.
28+
//
29+
30+
public struct ComposableView<Content: View>: View {
31+
32+
internal let name: String
33+
internal let props: [String: PropValue]
34+
internal let content: Content
35+
36+
public init(_ name: String, props: [String: PropValue] = [:], @ViewBuilder content: () -> Content) {
37+
self.name = name
38+
self.props = props
39+
self.content = content()
40+
}
41+
42+
public typealias Body = Never
43+
}
44+
45+
public extension ComposableView where Content == EmptyView {
46+
init(_ name: String, props: [String: PropValue] = [:]) {
47+
self.init(name, props: props) { EmptyView() }
48+
}
49+
}
50+
51+
extension ComposableView: PrimitiveView {
52+
public func _render(in context: ResolveContext) -> RenderNode {
53+
var props = self.props
54+
props["name"] = .string(name) // reserved: identifies the registered factory
55+
return RenderNode(
56+
type: "Composable",
57+
id: context.path,
58+
props: props,
59+
children: Evaluator.resolveChildren(content, context.descending("content"))
60+
)
61+
}
62+
}

AndroidSwiftUICore/Sources/AndroidSwiftUICore/RenderNode.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,29 @@ public enum PropValue: Equatable, Sendable {
1616
case array([PropValue])
1717
}
1818

19+
// Literal conveniences so custom-composable props read as a plain dictionary:
20+
// `["url": "https://…", "zoom": 1.5, "stars": 3, "on": true]`.
21+
extension PropValue: ExpressibleByStringLiteral {
22+
public init(stringLiteral value: String) { self = .string(value) }
23+
}
24+
extension PropValue: ExpressibleByFloatLiteral {
25+
public init(floatLiteral value: Double) { self = .double(value) }
26+
}
27+
extension PropValue: ExpressibleByIntegerLiteral {
28+
public init(integerLiteral value: Int) { self = .int(value) }
29+
}
30+
extension PropValue: ExpressibleByBooleanLiteral {
31+
public init(booleanLiteral value: Bool) { self = .bool(value) }
32+
}
33+
extension PropValue: ExpressibleByArrayLiteral {
34+
public init(arrayLiteral elements: PropValue...) { self = .array(elements) }
35+
}
36+
37+
public extension PropValue {
38+
/// A color as its 0xAARRGGBB integer, for passing to a custom composable.
39+
static func color(_ color: Color) -> PropValue { color.propValue }
40+
}
41+
1942
/// One entry in a node's ordered modifier chain. Order is significant:
2043
/// `.padding().background()` folds to a Compose `Modifier` in the same order.
2144
public struct ModifierNode: Equatable, Sendable {

AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,28 @@ struct GraphicsTests {
269269
#expect(node.props["url"] == .string("https://example.com/clip.mp4"))
270270
}
271271

272+
@Test("ComposableView emits a Composable node naming its factory with typed props")
273+
func composableView() {
274+
let node = ViewHost(
275+
ComposableView("RatingBar", props: ["rating": 3.5, "max": 5, "editable": true, "id": "abc"])
276+
).evaluate()
277+
#expect(node.type == "Composable")
278+
#expect(node.props["name"] == .string("RatingBar"))
279+
#expect(node.props["rating"] == .double(3.5))
280+
#expect(node.props["max"] == .int(5))
281+
#expect(node.props["editable"] == .bool(true))
282+
#expect(node.props["id"] == .string("abc"))
283+
}
284+
285+
@Test("ComposableView forwards child content")
286+
func composableViewChildren() {
287+
let node = ViewHost(
288+
ComposableView("DashedBorder") { Text("inside") }
289+
).evaluate()
290+
#expect(node.type == "Composable")
291+
#expect(firstTextString(node.children.first ?? node) == "inside")
292+
}
293+
272294
@Test("Overlay emits base and overlay children with alignment")
273295
func overlay() {
274296
let node = ViewHost(Color.blue.overlay(alignment: .bottomTrailing) { Text("badge") }).evaluate()

Demo/App.swiftpm/Sources/Catalog.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ struct CatalogEntry: Identifiable {
4444
CatalogEntry(id: "grid", title: "Grid", screen: AnyCatalogScreen(GridPlayground())),
4545
CatalogEntry(id: "form", title: "Form", screen: AnyCatalogScreen(FormPlayground())),
4646
CatalogEntry(id: "modifier", title: "Modifiers", screen: AnyCatalogScreen(ModifierPlayground())),
47+
CatalogEntry(id: "representable", title: "Custom Views", screen: AnyCatalogScreen(RepresentablePlayground())),
4748
CatalogEntry(id: "appearance", title: "Appearance", screen: AnyCatalogScreen(AppearancePlayground())),
4849
CatalogEntry(id: "animation", title: "Animation", screen: AnyCatalogScreen(AnimationPlayground())),
4950
CatalogEntry(id: "interaction", title: "Interaction", screen: AnyCatalogScreen(InteractionPlayground())),
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
#if canImport(AndroidSwiftUI)
2+
import AndroidSwiftUI
3+
#else
4+
import SwiftUI
5+
#endif
6+
7+
struct RepresentablePlayground: View {
8+
@State private var stars = 3.5
9+
var body: some View {
10+
ScrollView {
11+
VStack(alignment: .leading, spacing: 0) {
12+
Example("Native Android RatingBar") {
13+
VStack(alignment: .leading, spacing: 8) {
14+
ComposableView("RatingBar", props: ["rating": .double(stars), "max": 5])
15+
.frame(height: 60)
16+
HStack {
17+
Button("−½") { stars = max(0, stars - 0.5) }
18+
Button("") { stars = min(5, stars + 0.5) }
19+
Text("\(stars) / 5")
20+
}
21+
}
22+
}
23+
Example("Compose function with SwiftUI children") {
24+
ComposableView("DashedBorder", props: ["color": .color(.blue)]) {
25+
VStack(alignment: .leading, spacing: 6) {
26+
Text("These SwiftUI views").bold()
27+
Text("sit inside a Compose-drawn dashed border.")
28+
}
29+
}
30+
}
31+
Example("Unregistered name (diagnostic)") {
32+
ComposableView("DoesNotExist")
33+
.frame(height: 40)
34+
}
35+
}
36+
}
37+
}
38+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package com.pureswift.swiftandroid
2+
3+
import androidx.compose.foundation.layout.Box
4+
import androidx.compose.foundation.layout.padding
5+
import androidx.compose.ui.Modifier
6+
import androidx.compose.ui.draw.drawBehind
7+
import androidx.compose.ui.geometry.CornerRadius
8+
import androidx.compose.ui.graphics.Color
9+
import androidx.compose.ui.graphics.PathEffect
10+
import androidx.compose.ui.graphics.drawscope.Stroke
11+
import androidx.compose.ui.unit.dp
12+
import androidx.compose.ui.viewinterop.AndroidView
13+
import android.widget.RatingBar
14+
import com.pureswift.swiftui.ComposableRegistry
15+
16+
/// Registers the demo app's custom composables into the interpreter's registry.
17+
/// Called once before the first render. This is exactly what an app author
18+
/// does to expose a native Android view or a Compose function to SwiftUI.
19+
fun registerDemoComposables() {
20+
21+
// A native Android widget (android.widget.RatingBar) bridged through
22+
// Compose's AndroidView — the canonical "custom Android view" case.
23+
ComposableRegistry.register("RatingBar") { props, _ ->
24+
val rating = props.float("rating") ?: 0f
25+
val max = props.int("max") ?: 5
26+
AndroidView(
27+
factory = { context ->
28+
RatingBar(context).apply {
29+
numStars = max
30+
stepSize = 0.5f
31+
setIsIndicator(true)
32+
}
33+
},
34+
update = { bar ->
35+
bar.numStars = max
36+
bar.rating = rating
37+
},
38+
)
39+
}
40+
41+
// A pure Compose function drawing a dashed border around whatever SwiftUI
42+
// child content is passed into the slot.
43+
ComposableRegistry.register("DashedBorder") { props, children ->
44+
val color = props.color("color") ?: Color(0xFF6750A4.toInt())
45+
Box(
46+
modifier = Modifier
47+
.drawBehind {
48+
drawRoundRect(
49+
color = color,
50+
style = Stroke(
51+
width = 5f,
52+
pathEffect = PathEffect.dashPathEffect(floatArrayOf(24f, 14f)),
53+
),
54+
cornerRadius = CornerRadius(20f, 20f),
55+
)
56+
}
57+
.padding(18.dp),
58+
) {
59+
children()
60+
}
61+
}
62+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ class MainActivity : FragmentActivity() {
3131

3232
override fun onCreate(savedInstanceState: Bundle?) {
3333
super.onCreate(savedInstanceState)
34+
registerDemoComposables() // custom composables must be registered before the first render
3435
onCreateSwift(savedInstanceState)
3536
enableEdgeToEdge()
3637
}

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

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,36 @@ import androidx.compose.material3.Text
44
import androidx.compose.runtime.Composable
55
import androidx.compose.ui.graphics.Color
66
import kotlinx.serialization.json.JsonObject
7+
import kotlinx.serialization.json.JsonPrimitive
8+
import kotlinx.serialization.json.booleanOrNull
9+
import kotlinx.serialization.json.doubleOrNull
10+
import kotlinx.serialization.json.intOrNull
11+
import kotlinx.serialization.json.longOrNull
12+
13+
/// Typed access to a custom composable's props, so app authors read values
14+
/// without touching the underlying JSON layer (or depending on it).
15+
class Props internal constructor(private val json: JsonObject) {
16+
fun string(key: String): String? = (json[key] as? JsonPrimitive)?.content
17+
fun double(key: String): Double? = (json[key] as? JsonPrimitive)?.doubleOrNull
18+
fun float(key: String): Float? = double(key)?.toFloat()
19+
fun int(key: String): Int? = (json[key] as? JsonPrimitive)?.intOrNull
20+
fun bool(key: String): Boolean? = (json[key] as? JsonPrimitive)?.booleanOrNull
21+
/// A color passed from Swift as `PropValue.color(_:)` (an ARGB int).
22+
fun color(key: String): Color? = (json[key] as? JsonPrimitive)?.longOrNull?.let { Color(it.toInt()) }
23+
}
724

825
/// The library's single extension point: Kotlin registers named composables;
9-
/// Swift emits a `Composable(name:props:)` node referencing one. A registered
10-
/// factory receives the node's props and its rendered children slot. Entries
11-
/// registered from common code work on both Android and desktop; a factory
12-
/// that wraps an Android-only view simply isn't registered on desktop.
26+
/// Swift emits a `ComposableView(name:props:)` node referencing one. A factory
27+
/// receives typed props and a slot rendering the SwiftUI child content. Entries
28+
/// registered from common code work on both Android and desktop; a factory that
29+
/// wraps an Android-only view simply isn't registered on desktop.
1330
object ComposableRegistry {
1431

15-
fun interface Factory {
16-
@Composable
17-
fun Content(props: JsonObject, children: @Composable () -> Unit)
18-
}
19-
20-
private val factories = mutableMapOf<String, Factory>()
32+
// A registered factory: given props and a slot rendering the SwiftUI child
33+
// content, it emits composables. Stored as a plain composable function type.
34+
private val factories = mutableMapOf<String, @Composable (props: Props, children: @Composable () -> Unit) -> Unit>()
2135

22-
fun register(name: String, factory: Factory) {
36+
fun register(name: String, factory: @Composable (props: Props, children: @Composable () -> Unit) -> Unit) {
2337
factories[name] = factory
2438
}
2539

@@ -28,9 +42,13 @@ object ComposableRegistry {
2842
val name = node.string("name") ?: return
2943
val factory = factories[name]
3044
if (factory != null) {
31-
factory.Content(node.props) {
45+
factory(Props(node.props)) {
46+
// RenderChild, not the public Render: a nested call to a public
47+
// composable from this dynamically-invoked factory slot has its
48+
// restart group skipped by the runtime and silently renders
49+
// nothing.
3250
for (child in node.children) {
33-
Render(child)
51+
RenderChild(child)
3452
}
3553
}
3654
} else {

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,13 +157,21 @@ internal val LocalInheritedDisabled = compositionLocalOf { false }
157157
/// arriving does not change the composition's structure: a branch switch here
158158
/// would tear down every remembered Animatable and snap instead of easing.
159159
@Composable
160-
fun Render(node: ViewNode) {
160+
fun Render(node: ViewNode) = RenderChild(node)
161+
162+
/// The real entry point, shared by the public `Render` and the composable
163+
/// registry's child slot. The registry MUST call this directly rather than the
164+
/// public `Render`: a nested call to a public composable from a dynamically-
165+
/// invoked factory slot has its restart group skipped by the Compose runtime,
166+
/// so the whole subtree silently fails to compose. Routing through this
167+
/// internal function composes reliably. It also folds the node's own style
168+
/// modifiers into the inherited environment so its subtree sees them.
169+
@Composable
170+
internal fun RenderChild(node: ViewNode) {
161171
val spec = node.string("animationCurve")?.let {
162172
AnimSpec(it, (node.double("animationDurationMs") ?: 350.0).toInt())
163173
} ?: LocalAnimationSpec.current
164174

165-
// Fold this node's own style modifiers into the inherited environment so
166-
// its subtree sees them.
167175
var fontSize = LocalInheritedFontSize.current
168176
var fontWeight = LocalInheritedFontWeight.current
169177
var color = LocalInheritedColor.current

0 commit comments

Comments
 (0)