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
@@ -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<Self, _BorderModifier> {
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<Self, _ShadowModifier> {
modifier(_ShadowModifier(radius: radius))
}
func shadow(color: Color, radius: Double, x: Double = 0, y: Double = 0) -> ModifiedContent<Self, _ShadowModifier> {
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<S: _ShapeKind>(_ shape: S) -> ModifiedContent<Self, _ClipShapeModifier> {
modifier(_ClipShapeModifier(kind: shape._shapeKind, cornerRadius: shape._cornerRadius))
}
}
Original file line number Diff line number Diff line change
@@ -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<Base: View, Overlay: View>: 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<V: View>(
alignment: Alignment = .center,
@ViewBuilder content: () -> V
) -> _OverlayView<Self, V> {
_OverlayView(base: self, overlay: content(), alignment: alignment)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"))
}
}
50 changes: 50 additions & 0 deletions Demo/App.swiftpm/Sources/AppearancePlaygrounds.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
}
}
}
1 change: 1 addition & 0 deletions Demo/App.swiftpm/Sources/Catalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())),
Expand Down
41 changes: 37 additions & 4 deletions Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading