Skip to content

Commit 7e3521a

Browse files
authored
Merge pull request #32 from PureSwift/feature/appearance
Appearance: border, shadow, clipShape, and overlay
2 parents 12160da + 3a6fd24 commit 7e3521a

6 files changed

Lines changed: 242 additions & 4 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
//
2+
// AppearanceModifiers.swift
3+
// AndroidSwiftUICore
4+
//
5+
// Visual modifiers that fold into the Compose Modifier chain: border, shadow,
6+
// and clipShape.
7+
//
8+
9+
// MARK: - Border
10+
11+
public struct _BorderModifier: RenderModifier {
12+
let color: Color
13+
let width: Double
14+
public var _modifierNode: ModifierNode {
15+
ModifierNode(kind: "border", args: ["color": color.propValue, "width": .double(width)])
16+
}
17+
}
18+
19+
public extension View {
20+
func border(_ color: Color, width: Double = 1) -> ModifiedContent<Self, _BorderModifier> {
21+
modifier(_BorderModifier(color: color, width: width))
22+
}
23+
}
24+
25+
// MARK: - Shadow
26+
27+
public struct _ShadowModifier: RenderModifier {
28+
let radius: Double
29+
public var _modifierNode: ModifierNode {
30+
ModifierNode(kind: "shadow", args: ["radius": .double(radius)])
31+
}
32+
}
33+
34+
public extension View {
35+
func shadow(radius: Double, x: Double = 0, y: Double = 0) -> ModifiedContent<Self, _ShadowModifier> {
36+
modifier(_ShadowModifier(radius: radius))
37+
}
38+
func shadow(color: Color, radius: Double, x: Double = 0, y: Double = 0) -> ModifiedContent<Self, _ShadowModifier> {
39+
modifier(_ShadowModifier(radius: radius))
40+
}
41+
}
42+
43+
// MARK: - Clip shape
44+
45+
/// A shape reduced to the fields the interpreter needs to build a Compose shape.
46+
public protocol _ShapeKind {
47+
var _shapeKind: String { get }
48+
var _cornerRadius: Double? { get }
49+
}
50+
51+
extension Rectangle: _ShapeKind {
52+
public var _shapeKind: String { "rectangle" }
53+
public var _cornerRadius: Double? { nil }
54+
}
55+
extension Circle: _ShapeKind {
56+
public var _shapeKind: String { "circle" }
57+
public var _cornerRadius: Double? { nil }
58+
}
59+
extension Capsule: _ShapeKind {
60+
public var _shapeKind: String { "capsule" }
61+
public var _cornerRadius: Double? { nil }
62+
}
63+
extension RoundedRectangle: _ShapeKind {
64+
public var _shapeKind: String { "roundedRectangle" }
65+
public var _cornerRadius: Double? { cornerRadius }
66+
}
67+
68+
public struct _ClipShapeModifier: RenderModifier {
69+
let kind: String
70+
let cornerRadius: Double?
71+
public var _modifierNode: ModifierNode {
72+
var args: [String: PropValue] = ["shape": .string(kind)]
73+
if let cornerRadius { args["cornerRadius"] = .double(cornerRadius) }
74+
return ModifierNode(kind: "clipShape", args: args)
75+
}
76+
}
77+
78+
public extension View {
79+
func clipShape<S: _ShapeKind>(_ shape: S) -> ModifiedContent<Self, _ClipShapeModifier> {
80+
modifier(_ClipShapeModifier(kind: shape._shapeKind, cornerRadius: shape._cornerRadius))
81+
}
82+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
//
2+
// Overlay.swift
3+
// AndroidSwiftUICore
4+
//
5+
// `.overlay { … }` layers content over a view, sized to the base. Emitted as an
6+
// Overlay node whose first child is the base and second is the overlay; the
7+
// interpreter stacks the overlay at the base's size with the given alignment.
8+
//
9+
10+
public struct _OverlayView<Base: View, Overlay: View>: View {
11+
internal let base: Base
12+
internal let overlay: Overlay
13+
internal let alignment: Alignment
14+
public typealias Body = Never
15+
}
16+
17+
extension _OverlayView: PrimitiveView {
18+
public func _render(in context: ResolveContext) -> RenderNode {
19+
RenderNode(
20+
type: "Overlay",
21+
id: context.path,
22+
props: [
23+
"horizontal": .string(alignment.horizontal.rawValue),
24+
"vertical": .string(alignment.vertical.rawValue),
25+
],
26+
children: [
27+
Evaluator.resolve(base, context.descending("base")),
28+
Evaluator.resolve(overlay, context.descending("overlay")),
29+
]
30+
)
31+
}
32+
}
33+
34+
public extension View {
35+
func overlay<V: View>(
36+
alignment: Alignment = .center,
37+
@ViewBuilder content: () -> V
38+
) -> _OverlayView<Self, V> {
39+
_OverlayView(base: self, overlay: content(), alignment: alignment)
40+
}
41+
}

AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,26 @@ struct ModifierTests {
180180
let flag = node.modifiers.first { $0.kind == "disabled" }
181181
#expect(flag?.args["value"] == .bool(true))
182182
}
183+
184+
@Test("Border emits its color and width")
185+
func border() {
186+
let node = ViewHost(Text("x").border(.blue, width: 2)).evaluate()
187+
let border = node.modifiers.first { $0.kind == "border" }
188+
#expect(border?.args["color"] == Color.blue.propValue)
189+
#expect(border?.args["width"] == .double(2))
190+
}
191+
192+
@Test("clipShape emits the shape kind")
193+
func clipShape() {
194+
let node = ViewHost(Text("x").clipShape(Circle())).evaluate()
195+
#expect(node.modifiers.contains { $0.kind == "clipShape" && $0.args["shape"] == .string("circle") })
196+
}
197+
198+
@Test("shadow emits its radius")
199+
func shadow() {
200+
let node = ViewHost(Text("x").shadow(radius: 6)).evaluate()
201+
#expect(node.modifiers.first { $0.kind == "shadow" }?.args["radius"] == .double(6))
202+
}
183203
}
184204

185205
// MARK: - Graphics
@@ -217,4 +237,15 @@ struct GraphicsTests {
217237
#expect(node.type == "Image")
218238
#expect(node.props["systemName"] == .string("star.fill"))
219239
}
240+
241+
@Test("Overlay emits base and overlay children with alignment")
242+
func overlay() {
243+
let node = ViewHost(Color.blue.overlay(alignment: .bottomTrailing) { Text("badge") }).evaluate()
244+
#expect(node.type == "Overlay")
245+
#expect(node.children.count == 2)
246+
#expect(node.children[0].type == "Color")
247+
#expect(firstTextString(node.children[1]) == "badge")
248+
#expect(node.props["horizontal"] == .string("trailing"))
249+
#expect(node.props["vertical"] == .string("bottom"))
250+
}
220251
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
#if canImport(AndroidSwiftUI)
2+
import AndroidSwiftUI
3+
#else
4+
import SwiftUI
5+
#endif
6+
7+
struct AppearancePlayground: View {
8+
var body: some View {
9+
ScrollView {
10+
VStack(alignment: .leading, spacing: 0) {
11+
Example("Border") {
12+
Text("Bordered")
13+
.padding()
14+
.border(.blue, width: 2)
15+
}
16+
Example("Shadow") {
17+
RoundedRectangle(cornerRadius: 12)
18+
.fill(.white)
19+
.frame(width: 140, height: 64)
20+
.shadow(radius: 10)
21+
}
22+
Example("Clip to circle") {
23+
Color.blue.frame(width: 80, height: 80).clipShape(Circle())
24+
}
25+
Example("Clip to capsule") {
26+
Text("Capsule")
27+
.padding()
28+
.background(Color.orange)
29+
.clipShape(Capsule())
30+
}
31+
Example("Overlay badge") {
32+
Color.blue
33+
.frame(width: 80, height: 80)
34+
.cornerRadius(12)
35+
.overlay(alignment: .bottomTrailing) {
36+
Circle().fill(.red).frame(width: 26, height: 26)
37+
}
38+
}
39+
Example("Overlay text") {
40+
Color.green
41+
.frame(height: 80)
42+
.cornerRadius(8)
43+
.overlay {
44+
Text("Centered overlay").foregroundColor(.white).bold()
45+
}
46+
}
47+
}
48+
}
49+
}
50+
}

Demo/App.swiftpm/Sources/Catalog.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ struct CatalogEntry: Identifiable {
4141
CatalogEntry(id: "grid", title: "Grid", screen: AnyCatalogScreen(GridPlayground())),
4242
CatalogEntry(id: "form", title: "Form", screen: AnyCatalogScreen(FormPlayground())),
4343
CatalogEntry(id: "modifier", title: "Modifiers", screen: AnyCatalogScreen(ModifierPlayground())),
44+
CatalogEntry(id: "appearance", title: "Appearance", screen: AnyCatalogScreen(AppearancePlayground())),
4445
CatalogEntry(id: "interaction", title: "Interaction", screen: AnyCatalogScreen(InteractionPlayground())),
4546
CatalogEntry(id: "navigation", title: "Navigation", screen: AnyCatalogScreen(NavigationPlayground())),
4647
CatalogEntry(id: "tab", title: "TabView", screen: AnyCatalogScreen(TabViewPlayground())),

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

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.pureswift.swiftui
22

33
import androidx.compose.foundation.background
4+
import androidx.compose.foundation.border
45
import androidx.compose.foundation.clickable
56
import androidx.compose.foundation.rememberScrollState
67
import androidx.compose.foundation.horizontalScroll
@@ -94,6 +95,7 @@ import androidx.compose.ui.draw.alpha
9495
import androidx.compose.ui.draw.clip
9596
import androidx.compose.ui.draw.rotate
9697
import androidx.compose.ui.draw.scale
98+
import androidx.compose.ui.draw.shadow
9799
import androidx.compose.ui.graphics.Brush
98100
import androidx.compose.ui.graphics.Color
99101
import androidx.compose.ui.graphics.RectangleShape
@@ -193,13 +195,27 @@ fun Render(node: ViewNode) {
193195
}
194196
}
195197

198+
// A Color greedily fills the space offered it (SwiftUI semantics);
199+
// fillMaxWidth is applied after the chain so an explicit frame width
200+
// still constrains it.
196201
"Color" -> Box(
197202
modifier = node.composeModifiers()
203+
.fillMaxWidth()
198204
.background(Color((node.long("color") ?: 0).toInt()))
199205
)
200206

201207
"Image" -> RenderImage(node)
202208

209+
"Overlay" -> Box(
210+
contentAlignment = zStackAlignment(node),
211+
modifier = node.composeModifiers(),
212+
) {
213+
node.children.getOrNull(0)?.let { Render(it) }
214+
Box(modifier = Modifier.matchParentSize(), contentAlignment = zStackAlignment(node)) {
215+
node.children.getOrNull(1)?.let { Render(it) }
216+
}
217+
}
218+
203219
"Shape" -> RenderShape(node)
204220

205221
"LinearGradient" -> RenderGradient(node)
@@ -529,10 +545,10 @@ private fun RenderGradient(node: ViewNode) {
529545
startX == endX -> Brush.verticalGradient(colors)
530546
else -> Brush.linearGradient(colors)
531547
}
532-
// A gradient fills the available width by default (SwiftUI semantics); an
533-
// explicit frame width in the chain still constrains it. fillMaxWidth is
534-
// first so a cornerRadius clip in the chain sees the full width.
535-
Box(modifier = Modifier.fillMaxWidth().then(node.composeModifiers()).background(brush))
548+
// A gradient fills the available width by default (SwiftUI semantics);
549+
// fillMaxWidth is applied after the chain so an explicit frame width still
550+
// constrains it.
551+
Box(modifier = node.composeModifiers().fillMaxWidth().background(brush))
536552
}
537553

538554
@Composable
@@ -720,6 +736,23 @@ internal fun ViewNode.composeModifiers(): Modifier {
720736

721737
"opacity" -> modifier.alpha((entry.args.double("opacity") ?: 1.0).toFloat())
722738

739+
"border" -> {
740+
val color = entry.args.long("color")?.let { Color(it.toInt()) } ?: Color.Black
741+
modifier.border((entry.args.double("width") ?: 1.0).dp, color)
742+
}
743+
744+
"shadow" -> modifier.shadow((entry.args.double("radius") ?: 0.0).dp)
745+
746+
"clipShape" -> {
747+
val shape = when (entry.args.string("shape")) {
748+
"circle" -> CircleShape
749+
"capsule" -> RoundedCornerShape(percent = 50)
750+
"roundedRectangle" -> RoundedCornerShape((entry.args.double("cornerRadius") ?: 0.0).dp)
751+
else -> RectangleShape
752+
}
753+
modifier.clip(shape)
754+
}
755+
723756
"onTapGesture" -> {
724757
val id = entry.args.long("action")
725758
if (id != null) modifier.clickable { SwiftBridge.sink.invokeVoid(id) } else modifier

0 commit comments

Comments
 (0)