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,57 @@
//
// Gradient.swift
// AndroidSwiftUICore
//

/// A location in a view, normalized to its size (0…1 on each axis).
public struct UnitPoint: Equatable, Sendable {
public var x: Double
public var y: Double
public init(x: Double, y: Double) { self.x = x; self.y = y }

public static let leading = UnitPoint(x: 0, y: 0.5)
public static let trailing = UnitPoint(x: 1, y: 0.5)
public static let top = UnitPoint(x: 0.5, y: 0)
public static let bottom = UnitPoint(x: 0.5, y: 1)
public static let topLeading = UnitPoint(x: 0, y: 0)
public static let topTrailing = UnitPoint(x: 1, y: 0)
public static let bottomLeading = UnitPoint(x: 0, y: 1)
public static let bottomTrailing = UnitPoint(x: 1, y: 1)
public static let center = UnitPoint(x: 0.5, y: 0.5)
}

/// An ordered list of colors for a gradient.
public struct Gradient: Equatable, Sendable {
public var colors: [Color]
public init(colors: [Color]) { self.colors = colors }
}

/// A linear gradient fill, usable as a view.
public struct LinearGradient: View {

internal let colors: [Color]
internal let startPoint: UnitPoint
internal let endPoint: UnitPoint

public init(colors: [Color], startPoint: UnitPoint, endPoint: UnitPoint) {
self.colors = colors
self.startPoint = startPoint
self.endPoint = endPoint
}

public init(gradient: Gradient, startPoint: UnitPoint, endPoint: UnitPoint) {
self.init(colors: gradient.colors, startPoint: startPoint, endPoint: endPoint)
}

public typealias Body = Never
}

extension LinearGradient: PrimitiveView {
public func _render(in context: ResolveContext) -> RenderNode {
RenderNode(type: "LinearGradient", id: context.path, props: [
"colors": .array(colors.map { $0.propValue }),
"startX": .double(startPoint.x), "startY": .double(startPoint.y),
"endX": .double(endPoint.x), "endY": .double(endPoint.y),
])
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
//
// Shapes.swift
// AndroidSwiftUICore
//
// Shape views fill their frame with a fill color. A shape without an explicit
// `.frame` renders at zero size (the layout engine's fill-the-parent behavior
// is not modeled); the catalog always frames them.
//

private func shapeNode(_ kind: String, fill: Color?, cornerRadius: Double? = nil, context: ResolveContext) -> RenderNode {
var props: [String: PropValue] = ["shape": .string(kind)]
if let fill { props["fill"] = fill.propValue }
if let cornerRadius { props["cornerRadius"] = .double(cornerRadius) }
return RenderNode(type: "Shape", id: context.path, props: props)
}

public struct Rectangle: View {
internal var fillColor: Color?
public init() {}
public func fill(_ color: Color) -> Rectangle { var copy = self; copy.fillColor = color; return copy }
public typealias Body = Never
}

extension Rectangle: PrimitiveView {
public func _render(in context: ResolveContext) -> RenderNode {
shapeNode("rectangle", fill: fillColor, context: context)
}
}

public struct Circle: View {
internal var fillColor: Color?
public init() {}
public func fill(_ color: Color) -> Circle { var copy = self; copy.fillColor = color; return copy }
public typealias Body = Never
}

extension Circle: PrimitiveView {
public func _render(in context: ResolveContext) -> RenderNode {
shapeNode("circle", fill: fillColor, context: context)
}
}

public struct Capsule: View {
internal var fillColor: Color?
public init() {}
public func fill(_ color: Color) -> Capsule { var copy = self; copy.fillColor = color; return copy }
public typealias Body = Never
}

extension Capsule: PrimitiveView {
public func _render(in context: ResolveContext) -> RenderNode {
shapeNode("capsule", fill: fillColor, context: context)
}
}

public struct RoundedRectangle: View {
internal let cornerRadius: Double
internal var fillColor: Color?
public init(cornerRadius: Double) { self.cornerRadius = cornerRadius }
public func fill(_ color: Color) -> RoundedRectangle { var copy = self; copy.fillColor = color; return copy }
public typealias Body = Never
}

extension RoundedRectangle: PrimitiveView {
public func _render(in context: ResolveContext) -> RenderNode {
shapeNode("roundedRectangle", fill: fillColor, cornerRadius: cornerRadius, context: context)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,22 +43,32 @@ extension Color: PrimitiveView {
}
}

/// An image referenced by name. Rendering is placeholder-level until an asset
/// pipeline exists.
/// An image. `Image(systemName:)` maps a curated set of SF Symbol names to
/// Material icons in the interpreter; a named asset stays placeholder-level
/// until an asset pipeline exists.
public struct Image: View {

internal let name: String
internal let systemName: String?

public init(_ name: String) {
self.name = name
self.systemName = nil
}

public init(systemName: String) {
self.name = systemName
self.systemName = systemName
}

public typealias Body = Never
}

extension Image: PrimitiveView {
public func _render(in context: ResolveContext) -> RenderNode {
RenderNode(type: "Image", id: context.path, props: ["name": .string(name)])
var props: [String: PropValue] = ["name": .string(name)]
if let systemName { props["systemName"] = .string(systemName) }
return RenderNode(type: "Image", id: context.path, props: props)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,40 @@ struct ModifierTests {
#expect(flag?.args["value"] == .bool(true))
}
}

// MARK: - Graphics

@Suite("Graphics")
struct GraphicsTests {

@Test("Shapes emit a Shape node with their kind and fill")
func shapes() {
let rect = ViewHost(Rectangle().fill(.blue)).evaluate()
#expect(rect.type == "Shape")
#expect(rect.props["shape"] == .string("rectangle"))
#expect(rect.props["fill"] == Color.blue.propValue)

let rounded = ViewHost(RoundedRectangle(cornerRadius: 12)).evaluate()
#expect(rounded.props["shape"] == .string("roundedRectangle"))
#expect(rounded.props["cornerRadius"] == .double(12))

#expect(ViewHost(Circle()).evaluate().props["shape"] == .string("circle"))
#expect(ViewHost(Capsule()).evaluate().props["shape"] == .string("capsule"))
}

@Test("LinearGradient emits its colors and endpoints")
func gradient() {
let node = ViewHost(LinearGradient(colors: [.blue, .purple], startPoint: .leading, endPoint: .trailing)).evaluate()
#expect(node.type == "LinearGradient")
#expect(node.props["colors"] == .array([Color.blue.propValue, Color.purple.propValue]))
#expect(node.props["startX"] == .double(0))
#expect(node.props["endX"] == .double(1))
}

@Test("Image(systemName:) carries the symbol name")
func systemImage() {
let node = ViewHost(Image(systemName: "star.fill")).evaluate()
#expect(node.type == "Image")
#expect(node.props["systemName"] == .string("star.fill"))
}
}
1 change: 1 addition & 0 deletions Demo/App.swiftpm/Sources/Catalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ struct CatalogEntry: Identifiable {
CatalogEntry(id: "stack", title: "Stacks", screen: AnyCatalogScreen(StackPlayground())),
CatalogEntry(id: "spacer", title: "Spacer & Divider", screen: AnyCatalogScreen(SpacerDividerPlayground())),
CatalogEntry(id: "color", title: "Color", screen: AnyCatalogScreen(ColorPlayground())),
CatalogEntry(id: "graphics", title: "Graphics", screen: AnyCatalogScreen(GraphicsPlayground())),
CatalogEntry(id: "scroll", title: "ScrollView", screen: AnyCatalogScreen(ScrollViewPlayground())),
CatalogEntry(id: "list", title: "List", screen: AnyCatalogScreen(ListPlayground())),
CatalogEntry(id: "grid", title: "Grid", screen: AnyCatalogScreen(GridPlayground())),
Expand Down
48 changes: 48 additions & 0 deletions Demo/App.swiftpm/Sources/GraphicsPlaygrounds.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#if canImport(AndroidSwiftUI)
import AndroidSwiftUI
#else
import SwiftUI
#endif

struct GraphicsPlayground: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Example("Shapes") {
HStack(spacing: 12) {
Rectangle().fill(.blue).frame(width: 60, height: 60)
Circle().fill(.green).frame(width: 60, height: 60)
Capsule().fill(.orange).frame(width: 90, height: 40)
RoundedRectangle(cornerRadius: 12).fill(.purple).frame(width: 60, height: 60)
}
}
Example("Linear gradient (horizontal)") {
LinearGradient(colors: [.blue, .purple], startPoint: .leading, endPoint: .trailing)
.frame(height: 60)
.cornerRadius(8)
}
Example("Linear gradient (vertical)") {
LinearGradient(colors: [.orange, .red, .pink], startPoint: .top, endPoint: .bottom)
.frame(height: 80)
.cornerRadius(8)
}
Example("SF Symbols") {
HStack(spacing: 16) {
Image(systemName: "star.fill").foregroundColor(.yellow)
Image(systemName: "heart.fill").foregroundColor(.red)
Image(systemName: "trash").foregroundColor(.gray)
Image(systemName: "gear")
Image(systemName: "bell.fill").foregroundColor(.blue)
}
}
Example("Symbols sized") {
HStack(spacing: 16) {
Image(systemName: "star.fill").frame(width: 40, height: 40).foregroundColor(.orange)
Image(systemName: "cart.fill").frame(width: 40, height: 40).foregroundColor(.green)
Image(systemName: "person.fill").frame(width: 40, height: 40).foregroundColor(.blue)
}
}
}
}
}
}
Loading
Loading