Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//
// ComposableView.swift
// AndroidSwiftUICore
//
// The library's escape hatch for custom platform UI — the counterpart to
// UIKit's `UIViewRepresentable`. Because a `@Composable` (or an Android
// `View`) can never be authored in Swift, the seam is a *named registry*:
// Kotlin registers `name → composable` factories once at startup, and this
// view references one by name, forwarding typed props and an optional slot of
// SwiftUI child content.
//
// Kotlin side:
//
// ComposableRegistry.register("RatingBar") { props, _ ->
// AndroidView(factory = { RatingBar(it) }, update = {
// it.rating = (props["rating"] as? JsonPrimitive)?.floatOrNull ?: 0f
// })
// }
//
// Swift side:
//
// ComposableView("RatingBar", props: ["rating": 3.5, "max": 5])
//
// A factory that wraps an Android-only `View` (maps, WebView, an ad SDK) uses
// Compose's own `AndroidView` inside the registered composable — so arbitrary
// custom Android views compose into a SwiftUI tree. An unregistered name
// renders a visible diagnostic rather than failing silently.
//

public struct ComposableView<Content: View>: View {

internal let name: String
internal let props: [String: PropValue]
internal let content: Content

public init(_ name: String, props: [String: PropValue] = [:], @ViewBuilder content: () -> Content) {
self.name = name
self.props = props
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() }
}
}

extension ComposableView: PrimitiveView {
public func _render(in context: ResolveContext) -> RenderNode {
var props = self.props
props["name"] = .string(name) // reserved: identifies the registered factory
return RenderNode(
type: "Composable",
id: context.path,
props: props,
children: Evaluator.resolveChildren(content, context.descending("content"))
)
}
}
23 changes: 23 additions & 0 deletions AndroidSwiftUICore/Sources/AndroidSwiftUICore/RenderNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,29 @@ public enum PropValue: Equatable, Sendable {
case array([PropValue])
}

// Literal conveniences so custom-composable props read as a plain dictionary:
// `["url": "https://…", "zoom": 1.5, "stars": 3, "on": true]`.
extension PropValue: ExpressibleByStringLiteral {
public init(stringLiteral value: String) { self = .string(value) }
}
extension PropValue: ExpressibleByFloatLiteral {
public init(floatLiteral value: Double) { self = .double(value) }
}
extension PropValue: ExpressibleByIntegerLiteral {
public init(integerLiteral value: Int) { self = .int(value) }
}
extension PropValue: ExpressibleByBooleanLiteral {
public init(booleanLiteral value: Bool) { self = .bool(value) }
}
extension PropValue: ExpressibleByArrayLiteral {
public init(arrayLiteral elements: PropValue...) { self = .array(elements) }
}

public extension PropValue {
/// A color as its 0xAARRGGBB integer, for passing to a custom composable.
static func color(_ color: Color) -> PropValue { color.propValue }
}

/// One entry in a node's ordered modifier chain. Order is significant:
/// `.padding().background()` folds to a Compose `Modifier` in the same order.
public struct ModifierNode: Equatable, Sendable {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,28 @@ struct GraphicsTests {
#expect(node.props["url"] == .string("https://example.com/clip.mp4"))
}

@Test("ComposableView emits a Composable node naming its factory with typed props")
func composableView() {
let node = ViewHost(
ComposableView("RatingBar", props: ["rating": 3.5, "max": 5, "editable": true, "id": "abc"])
).evaluate()
#expect(node.type == "Composable")
#expect(node.props["name"] == .string("RatingBar"))
#expect(node.props["rating"] == .double(3.5))
#expect(node.props["max"] == .int(5))
#expect(node.props["editable"] == .bool(true))
#expect(node.props["id"] == .string("abc"))
}

@Test("ComposableView forwards child content")
func composableViewChildren() {
let node = ViewHost(
ComposableView("DashedBorder") { Text("inside") }
).evaluate()
#expect(node.type == "Composable")
#expect(firstTextString(node.children.first ?? node) == "inside")
}

@Test("Overlay emits base and overlay children with alignment")
func overlay() {
let node = ViewHost(Color.blue.overlay(alignment: .bottomTrailing) { Text("badge") }).evaluate()
Expand Down
1 change: 1 addition & 0 deletions Demo/App.swiftpm/Sources/Catalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ struct CatalogEntry: Identifiable {
CatalogEntry(id: "grid", title: "Grid", screen: AnyCatalogScreen(GridPlayground())),
CatalogEntry(id: "form", title: "Form", screen: AnyCatalogScreen(FormPlayground())),
CatalogEntry(id: "modifier", title: "Modifiers", screen: AnyCatalogScreen(ModifierPlayground())),
CatalogEntry(id: "representable", title: "Custom Views", screen: AnyCatalogScreen(RepresentablePlayground())),
CatalogEntry(id: "appearance", title: "Appearance", screen: AnyCatalogScreen(AppearancePlayground())),
CatalogEntry(id: "animation", title: "Animation", screen: AnyCatalogScreen(AnimationPlayground())),
CatalogEntry(id: "interaction", title: "Interaction", screen: AnyCatalogScreen(InteractionPlayground())),
Expand Down
38 changes: 38 additions & 0 deletions Demo/App.swiftpm/Sources/RepresentablePlaygrounds.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#if canImport(AndroidSwiftUI)
import AndroidSwiftUI
#else
import SwiftUI
#endif

struct RepresentablePlayground: View {
@State private var stars = 3.5
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Example("Native Android RatingBar") {
VStack(alignment: .leading, spacing: 8) {
ComposableView("RatingBar", props: ["rating": .double(stars), "max": 5])
.frame(height: 60)
HStack {
Button("−½") { stars = max(0, stars - 0.5) }
Button("+½") { stars = min(5, stars + 0.5) }
Text("\(stars) / 5")
}
}
}
Example("Compose function with SwiftUI children") {
ComposableView("DashedBorder", props: ["color": .color(.blue)]) {
VStack(alignment: .leading, spacing: 6) {
Text("These SwiftUI views").bold()
Text("sit inside a Compose-drawn dashed border.")
}
}
}
Example("Unregistered name (diagnostic)") {
ComposableView("DoesNotExist")
.frame(height: 40)
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.pureswift.swiftandroid

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import android.widget.RatingBar
import com.pureswift.swiftui.ComposableRegistry

/// Registers the demo app's custom composables into the interpreter's registry.
/// Called once before the first render. This is exactly what an app author
/// does to expose a native Android view or a Compose function to SwiftUI.
fun registerDemoComposables() {

// A native Android widget (android.widget.RatingBar) bridged through
// Compose's AndroidView — the canonical "custom Android view" case.
ComposableRegistry.register("RatingBar") { props, _ ->
val rating = props.float("rating") ?: 0f
val max = props.int("max") ?: 5
AndroidView(
factory = { context ->
RatingBar(context).apply {
numStars = max
stepSize = 0.5f
setIsIndicator(true)
}
},
update = { bar ->
bar.numStars = max
bar.rating = rating
},
)
}

// A pure Compose function drawing a dashed border around whatever SwiftUI
// child content is passed into the slot.
ComposableRegistry.register("DashedBorder") { props, children ->
val color = props.color("color") ?: Color(0xFF6750A4.toInt())
Box(
modifier = Modifier
.drawBehind {
drawRoundRect(
color = color,
style = Stroke(
width = 5f,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(24f, 14f)),
),
cornerRadius = CornerRadius(20f, 20f),
)
}
.padding(18.dp),
) {
children()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class MainActivity : FragmentActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
registerDemoComposables() // custom composables must be registered before the first render
onCreateSwift(savedInstanceState)
enableEdgeToEdge()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,36 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull

/// Typed access to a custom composable's props, so app authors read values
/// without touching the underlying JSON layer (or depending on it).
class Props internal constructor(private val json: JsonObject) {
fun string(key: String): String? = (json[key] as? JsonPrimitive)?.content
fun double(key: String): Double? = (json[key] as? JsonPrimitive)?.doubleOrNull
fun float(key: String): Float? = double(key)?.toFloat()
fun int(key: String): Int? = (json[key] as? JsonPrimitive)?.intOrNull
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()) }
}

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

fun interface Factory {
@Composable
fun Content(props: JsonObject, children: @Composable () -> Unit)
}

private val factories = mutableMapOf<String, Factory>()
// A registered factory: given props and a slot rendering the SwiftUI child
// content, it emits composables. Stored as a plain composable function type.
private val factories = mutableMapOf<String, @Composable (props: Props, children: @Composable () -> Unit) -> Unit>()

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

Expand All @@ -28,9 +42,13 @@ object ComposableRegistry {
val name = node.string("name") ?: return
val factory = factories[name]
if (factory != null) {
factory.Content(node.props) {
factory(Props(node.props)) {
// RenderChild, not the public Render: a nested call to a public
// composable from this dynamically-invoked factory slot has its
// restart group skipped by the runtime and silently renders
// nothing.
for (child in node.children) {
Render(child)
RenderChild(child)
}
}
} else {
Expand Down
14 changes: 11 additions & 3 deletions Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt
Original file line number Diff line number Diff line change
Expand Up @@ -157,13 +157,21 @@ internal val LocalInheritedDisabled = compositionLocalOf { false }
/// arriving does not change the composition's structure: a branch switch here
/// would tear down every remembered Animatable and snap instead of easing.
@Composable
fun Render(node: ViewNode) {
fun Render(node: ViewNode) = RenderChild(node)

/// The real entry point, shared by the public `Render` and the composable
/// registry's child slot. The registry MUST call this directly rather than the
/// public `Render`: a nested call to a public composable from a dynamically-
/// invoked factory slot has its restart group skipped by the Compose runtime,
/// so the whole subtree silently fails to compose. Routing through this
/// internal function composes reliably. It also folds the node's own style
/// modifiers into the inherited environment so its subtree sees them.
@Composable
internal fun RenderChild(node: ViewNode) {
val spec = node.string("animationCurve")?.let {
AnimSpec(it, (node.double("animationDurationMs") ?: 350.0).toInt())
} ?: LocalAnimationSpec.current

// Fold this node's own style modifiers into the inherited environment so
// its subtree sees them.
var fontSize = LocalInheritedFontSize.current
var fontWeight = LocalInheritedFontWeight.current
var color = LocalInheritedColor.current
Expand Down
Loading