diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Modifiers/AppearanceModifiers.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Modifiers/AppearanceModifiers.swift new file mode 100644 index 0000000..22ac749 --- /dev/null +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Modifiers/AppearanceModifiers.swift @@ -0,0 +1,82 @@ +// +// AppearanceModifiers.swift +// AndroidSwiftUICore +// +// Visual modifiers that fold into the Compose Modifier chain: border, shadow, +// and clipShape. +// + +// MARK: - Border + +public struct _BorderModifier: RenderModifier { + let color: Color + let width: Double + public var _modifierNode: ModifierNode { + ModifierNode(kind: "border", args: ["color": color.propValue, "width": .double(width)]) + } +} + +public extension View { + func border(_ color: Color, width: Double = 1) -> ModifiedContent { + modifier(_BorderModifier(color: color, width: width)) + } +} + +// MARK: - Shadow + +public struct _ShadowModifier: RenderModifier { + let radius: Double + public var _modifierNode: ModifierNode { + ModifierNode(kind: "shadow", args: ["radius": .double(radius)]) + } +} + +public extension View { + func shadow(radius: Double, x: Double = 0, y: Double = 0) -> ModifiedContent { + modifier(_ShadowModifier(radius: radius)) + } + func shadow(color: Color, radius: Double, x: Double = 0, y: Double = 0) -> ModifiedContent { + modifier(_ShadowModifier(radius: radius)) + } +} + +// MARK: - Clip shape + +/// A shape reduced to the fields the interpreter needs to build a Compose shape. +public protocol _ShapeKind { + var _shapeKind: String { get } + var _cornerRadius: Double? { get } +} + +extension Rectangle: _ShapeKind { + public var _shapeKind: String { "rectangle" } + public var _cornerRadius: Double? { nil } +} +extension Circle: _ShapeKind { + public var _shapeKind: String { "circle" } + public var _cornerRadius: Double? { nil } +} +extension Capsule: _ShapeKind { + public var _shapeKind: String { "capsule" } + public var _cornerRadius: Double? { nil } +} +extension RoundedRectangle: _ShapeKind { + public var _shapeKind: String { "roundedRectangle" } + public var _cornerRadius: Double? { cornerRadius } +} + +public struct _ClipShapeModifier: RenderModifier { + let kind: String + let cornerRadius: Double? + public var _modifierNode: ModifierNode { + var args: [String: PropValue] = ["shape": .string(kind)] + if let cornerRadius { args["cornerRadius"] = .double(cornerRadius) } + return ModifierNode(kind: "clipShape", args: args) + } +} + +public extension View { + func clipShape(_ shape: S) -> ModifiedContent { + modifier(_ClipShapeModifier(kind: shape._shapeKind, cornerRadius: shape._cornerRadius)) + } +} diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Overlay.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Overlay.swift new file mode 100644 index 0000000..b0ae563 --- /dev/null +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Overlay.swift @@ -0,0 +1,41 @@ +// +// Overlay.swift +// AndroidSwiftUICore +// +// `.overlay { … }` layers content over a view, sized to the base. Emitted as an +// Overlay node whose first child is the base and second is the overlay; the +// interpreter stacks the overlay at the base's size with the given alignment. +// + +public struct _OverlayView: View { + internal let base: Base + internal let overlay: Overlay + internal let alignment: Alignment + public typealias Body = Never +} + +extension _OverlayView: PrimitiveView { + public func _render(in context: ResolveContext) -> RenderNode { + RenderNode( + type: "Overlay", + id: context.path, + props: [ + "horizontal": .string(alignment.horizontal.rawValue), + "vertical": .string(alignment.vertical.rawValue), + ], + children: [ + Evaluator.resolve(base, context.descending("base")), + Evaluator.resolve(overlay, context.descending("overlay")), + ] + ) + } +} + +public extension View { + func overlay( + alignment: Alignment = .center, + @ViewBuilder content: () -> V + ) -> _OverlayView { + _OverlayView(base: self, overlay: content(), alignment: alignment) + } +} diff --git a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift index c7f2727..5315850 100644 --- a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift +++ b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift @@ -180,6 +180,26 @@ struct ModifierTests { let flag = node.modifiers.first { $0.kind == "disabled" } #expect(flag?.args["value"] == .bool(true)) } + + @Test("Border emits its color and width") + func border() { + let node = ViewHost(Text("x").border(.blue, width: 2)).evaluate() + let border = node.modifiers.first { $0.kind == "border" } + #expect(border?.args["color"] == Color.blue.propValue) + #expect(border?.args["width"] == .double(2)) + } + + @Test("clipShape emits the shape kind") + func clipShape() { + let node = ViewHost(Text("x").clipShape(Circle())).evaluate() + #expect(node.modifiers.contains { $0.kind == "clipShape" && $0.args["shape"] == .string("circle") }) + } + + @Test("shadow emits its radius") + func shadow() { + let node = ViewHost(Text("x").shadow(radius: 6)).evaluate() + #expect(node.modifiers.first { $0.kind == "shadow" }?.args["radius"] == .double(6)) + } } // MARK: - Graphics @@ -217,4 +237,15 @@ struct GraphicsTests { #expect(node.type == "Image") #expect(node.props["systemName"] == .string("star.fill")) } + + @Test("Overlay emits base and overlay children with alignment") + func overlay() { + let node = ViewHost(Color.blue.overlay(alignment: .bottomTrailing) { Text("badge") }).evaluate() + #expect(node.type == "Overlay") + #expect(node.children.count == 2) + #expect(node.children[0].type == "Color") + #expect(firstTextString(node.children[1]) == "badge") + #expect(node.props["horizontal"] == .string("trailing")) + #expect(node.props["vertical"] == .string("bottom")) + } } diff --git a/Demo/App.swiftpm/Sources/AppearancePlaygrounds.swift b/Demo/App.swiftpm/Sources/AppearancePlaygrounds.swift new file mode 100644 index 0000000..992e512 --- /dev/null +++ b/Demo/App.swiftpm/Sources/AppearancePlaygrounds.swift @@ -0,0 +1,50 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif + +struct AppearancePlayground: View { + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Example("Border") { + Text("Bordered") + .padding() + .border(.blue, width: 2) + } + Example("Shadow") { + RoundedRectangle(cornerRadius: 12) + .fill(.white) + .frame(width: 140, height: 64) + .shadow(radius: 10) + } + Example("Clip to circle") { + Color.blue.frame(width: 80, height: 80).clipShape(Circle()) + } + Example("Clip to capsule") { + Text("Capsule") + .padding() + .background(Color.orange) + .clipShape(Capsule()) + } + Example("Overlay badge") { + Color.blue + .frame(width: 80, height: 80) + .cornerRadius(12) + .overlay(alignment: .bottomTrailing) { + Circle().fill(.red).frame(width: 26, height: 26) + } + } + Example("Overlay text") { + Color.green + .frame(height: 80) + .cornerRadius(8) + .overlay { + Text("Centered overlay").foregroundColor(.white).bold() + } + } + } + } + } +} diff --git a/Demo/App.swiftpm/Sources/Catalog.swift b/Demo/App.swiftpm/Sources/Catalog.swift index d4ee1fe..591d140 100644 --- a/Demo/App.swiftpm/Sources/Catalog.swift +++ b/Demo/App.swiftpm/Sources/Catalog.swift @@ -41,6 +41,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: "appearance", title: "Appearance", screen: AnyCatalogScreen(AppearancePlayground())), CatalogEntry(id: "interaction", title: "Interaction", screen: AnyCatalogScreen(InteractionPlayground())), CatalogEntry(id: "navigation", title: "Navigation", screen: AnyCatalogScreen(NavigationPlayground())), CatalogEntry(id: "tab", title: "TabView", screen: AnyCatalogScreen(TabViewPlayground())), diff --git a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt index 83e3802..ff96933 100644 --- a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt +++ b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt @@ -1,6 +1,7 @@ package com.pureswift.swiftui import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.horizontalScroll @@ -94,6 +95,7 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.scale +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape @@ -193,13 +195,27 @@ fun Render(node: ViewNode) { } } + // A Color greedily fills the space offered it (SwiftUI semantics); + // fillMaxWidth is applied after the chain so an explicit frame width + // still constrains it. "Color" -> Box( modifier = node.composeModifiers() + .fillMaxWidth() .background(Color((node.long("color") ?: 0).toInt())) ) "Image" -> RenderImage(node) + "Overlay" -> Box( + contentAlignment = zStackAlignment(node), + modifier = node.composeModifiers(), + ) { + node.children.getOrNull(0)?.let { Render(it) } + Box(modifier = Modifier.matchParentSize(), contentAlignment = zStackAlignment(node)) { + node.children.getOrNull(1)?.let { Render(it) } + } + } + "Shape" -> RenderShape(node) "LinearGradient" -> RenderGradient(node) @@ -529,10 +545,10 @@ private fun RenderGradient(node: ViewNode) { startX == endX -> Brush.verticalGradient(colors) else -> Brush.linearGradient(colors) } - // A gradient fills the available width by default (SwiftUI semantics); an - // explicit frame width in the chain still constrains it. fillMaxWidth is - // first so a cornerRadius clip in the chain sees the full width. - Box(modifier = Modifier.fillMaxWidth().then(node.composeModifiers()).background(brush)) + // A gradient fills the available width by default (SwiftUI semantics); + // fillMaxWidth is applied after the chain so an explicit frame width still + // constrains it. + Box(modifier = node.composeModifiers().fillMaxWidth().background(brush)) } @Composable @@ -720,6 +736,23 @@ internal fun ViewNode.composeModifiers(): Modifier { "opacity" -> modifier.alpha((entry.args.double("opacity") ?: 1.0).toFloat()) + "border" -> { + val color = entry.args.long("color")?.let { Color(it.toInt()) } ?: Color.Black + modifier.border((entry.args.double("width") ?: 1.0).dp, color) + } + + "shadow" -> modifier.shadow((entry.args.double("radius") ?: 0.0).dp) + + "clipShape" -> { + val shape = when (entry.args.string("shape")) { + "circle" -> CircleShape + "capsule" -> RoundedCornerShape(percent = 50) + "roundedRectangle" -> RoundedCornerShape((entry.args.double("cornerRadius") ?: 0.0).dp) + else -> RectangleShape + } + modifier.clip(shape) + } + "onTapGesture" -> { val id = entry.args.long("action") if (id != null) modifier.clickable { SwiftBridge.sink.invokeVoid(id) } else modifier