Skip to content

Commit 84a3a2e

Browse files
committed
initial implementation of path
1 parent 70056b2 commit 84a3a2e

File tree

6 files changed

+456
-0
lines changed

6 files changed

+456
-0
lines changed

Sources/SwiftCrossUI/Backend/AppBackend.swift

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ public protocol AppBackend {
4444
associatedtype Widget
4545
associatedtype Menu
4646
associatedtype Alert
47+
associatedtype Path
4748

4849
/// Creates an instance of the backend.
4950
init()
@@ -527,6 +528,24 @@ public protocol AppBackend {
527528
gesture: TapGesture,
528529
action: @escaping () -> Void
529530
)
531+
532+
// MARK: Paths
533+
/// Create a path. It will not be shown until ``renderPath(_:container:)`` is called.
534+
func createPath() -> Path
535+
/// Update a path. The updates do not need to be visible before ``renderPath(_:container:)``
536+
/// is called.
537+
/// - Parameters:
538+
/// - path: The path to be updated.
539+
/// - source: The source to copy the path from.
540+
/// - pointsChanged: If `false`, the ``Path/actions`` of the source have not changed.
541+
func updatePath(_ path: Path, _ source: SwiftCrossUI.Path, pointsChanged: Bool)
542+
/// Draw a path to the screen.
543+
/// - Parameters:
544+
/// - path: The path to be rendered.
545+
/// - container: The container widget that the path will render in. It has no other
546+
/// children.
547+
/// - environment: The environment values, including color.
548+
func renderPath(_ path: Path, container: Widget, environment: EnvironmentValues)
530549
}
531550

532551
extension AppBackend {
@@ -843,4 +862,15 @@ extension AppBackend {
843862
) {
844863
todo()
845864
}
865+
866+
// MARK: Paths
867+
func createPath() -> Path {
868+
todo()
869+
}
870+
func updatePath(_ path: Path, _ source: SwiftCrossUI.Path, pointsChanged: Bool) {
871+
todo()
872+
}
873+
func renderPath(_ path: Path, container: Widget, environment: EnvironmentValues) {
874+
todo()
875+
}
846876
}

Sources/SwiftCrossUI/Path.swift

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import Foundation // for sinf and cosf
2+
3+
public enum StrokeCap {
4+
/// The stroke ends square exactly at the last point.
5+
case butt
6+
/// The stroke ends with a semicircle.
7+
case round
8+
/// The stroke ends square half of the stroke width past the last point.
9+
case square
10+
}
11+
12+
public enum StrokeJoin {
13+
/// Corners are sharp, unless they are longer than `limit` times half the stroke width,
14+
/// in which case they are beveled.
15+
case miter(limit: Float)
16+
/// Corners are rounded.
17+
case round
18+
/// Corners are beveled.
19+
case bevel
20+
}
21+
22+
public struct StrokeStyle {
23+
public var width: Float = 1.0
24+
public var cap: StrokeCap = .butt
25+
public var join: StrokeJoin = .miter(limit: 10.0)
26+
27+
public static let none = StrokeStyle(width: 0.0)
28+
}
29+
30+
/// An enum describing how a path is shaded.
31+
public enum FillRule {
32+
/// A region is shaded if it is enclosed an odd number of times.
33+
case evenOdd
34+
/// A region is shaded if it is enclosed at all.
35+
///
36+
/// This is also known as the "non-zero" rule.
37+
case winding
38+
}
39+
40+
/// A type representing an affine transformation on a 2-D point.
41+
///
42+
/// Performing an affine transform consists of translating by ``translation`` followed by
43+
/// multiplying by ``linearTransform``.
44+
public struct AffineTransform: Equatable {
45+
/// The linear transformation. This is a 2x2 matrix stored in row-major order.
46+
///
47+
/// The four properties (`x`, `y`, `z`, `w`) correspond to the 2x2 matrix as follows:
48+
/// ```
49+
/// [ x y ]
50+
/// [ z w ]
51+
/// ```
52+
public var linearTransform: SIMD4<Float>
53+
/// The translation applied after the linear transformation.
54+
public var translation: SIMD2<Float>
55+
56+
public init(linearTransform: SIMD4<Float>, translation: SIMD2<Float>) {
57+
self.linearTransform = linearTransform
58+
self.translation = translation
59+
}
60+
61+
public static func translate(x: Float, y: Float) -> AffineTransform {
62+
AffineTransform(
63+
linearTransform: SIMD4(x: 1.0, y: 0.0, z: 0.0, w: 1.0),
64+
translation: SIMD2(x: x, y: y)
65+
)
66+
}
67+
68+
public static func scale(by factor: Float) -> AffineTransform {
69+
AffineTransform(
70+
linearTransform: SIMD4(x: factor, y: 0.0, z: 0.0, w: factor),
71+
translation: .zero
72+
)
73+
}
74+
75+
public static func rotate(
76+
radians: Float,
77+
center: SIMD2<Float> = .zero
78+
) -> AffineTransform {
79+
let sine = sinf(radians)
80+
let cosine = cosf(radians)
81+
return AffineTransform(
82+
linearTransform: SIMD4(x: cosine, y: -sine, z: sine, w: cosine),
83+
translation: SIMD2(
84+
x: -center.x * cosine - center.y * sine + center.x,
85+
y: center.x * sine - center.y * cosine + center.y
86+
)
87+
)
88+
}
89+
90+
public static func rotate(
91+
degrees: Float,
92+
center: SIMD2<Float> = .zero
93+
) -> AffineTransform {
94+
rotate(radians: degrees * (.pi / 180.0), center: center)
95+
}
96+
97+
public static let identity = AffineTransform(
98+
linearTransform: SIMD4(x: 1.0, y: 0.0, z: 0.0, w: 1.0),
99+
translation: .zero
100+
)
101+
}
102+
103+
public struct Path {
104+
public struct Rect: Equatable {
105+
public var origin: SIMD2<Float>
106+
public var size: SIMD2<Float>
107+
108+
public init(origin: SIMD2<Float>, size: SIMD2<Float>) {
109+
self.origin = origin
110+
self.size = size
111+
}
112+
113+
public var x: Float { origin.x }
114+
public var y: Float { origin.y }
115+
public var width: Float { size.x }
116+
public var height: Float { size.y }
117+
118+
public init(x: Float, y: Float, width: Float, height: Float) {
119+
origin = SIMD2(x: x, y: y)
120+
size = SIMD2(x: width, y: height)
121+
}
122+
}
123+
124+
/// The types of actions that can be performed on a path.
125+
public enum Action: Equatable {
126+
case moveTo(SIMD2<Float>)
127+
case lineTo(SIMD2<Float>)
128+
case quadCurve(control: SIMD2<Float>, end: SIMD2<Float>)
129+
case cubicCurve(control1: SIMD2<Float>, control2: SIMD2<Float>, end: SIMD2<Float>)
130+
case rectangle(Rect)
131+
case circle(center: SIMD2<Float>, radius: Float)
132+
case arc(
133+
center: SIMD2<Float>,
134+
radius: Float,
135+
startAngle: Float,
136+
endAngle: Float,
137+
clockwise: Bool
138+
)
139+
case transform(AffineTransform)
140+
// case subpath([Action], FillRule)
141+
}
142+
143+
/// A list of every action that has been performed on this path.
144+
///
145+
/// This property is meant for backends implementing paths. If the backend has a similar
146+
/// path type built-in (such as `UIBezierPath` or `GskPathBuilder`), constructing the
147+
/// path should consist of looping over this array and calling the method that corresponds
148+
/// to each action.
149+
public private(set) var actions: [Action] = []
150+
public private(set) var fillRule: FillRule = .evenOdd
151+
public private(set) var strokeStyle: StrokeStyle = .none
152+
153+
public init() {}
154+
155+
public consuming func move(to point: SIMD2<Float>) -> Path {
156+
actions.append(.moveTo(point))
157+
return self
158+
}
159+
160+
public consuming func addLine(to point: SIMD2<Float>) -> Path {
161+
actions.append(.lineTo(point))
162+
return self
163+
}
164+
165+
public consuming func addQuadCurve(
166+
control: SIMD2<Float>,
167+
to endPoint: SIMD2<Float>
168+
) -> Path {
169+
actions.append(.quadCurve(control: control, end: endPoint))
170+
return self
171+
}
172+
173+
public consuming func addCubicCurve(
174+
control1: SIMD2<Float>,
175+
control2: SIMD2<Float>,
176+
to endPoint: SIMD2<Float>
177+
) -> Path {
178+
actions.append(.cubicCurve(control1: control1, control2: control2, end: endPoint))
179+
return self
180+
}
181+
182+
public consuming func addRectangle(_ rect: Rect) -> Path {
183+
actions.append(.rectangle(rect))
184+
return self
185+
}
186+
187+
public consuming func addCircle(center: SIMD2<Float>, radius: Float) -> Path {
188+
actions.append(.circle(center: center, radius: radius))
189+
return self
190+
}
191+
192+
public consuming func addArc(
193+
center: SIMD2<Float>,
194+
radius: Float,
195+
startAngle: Float,
196+
endAngle: Float,
197+
clockwise: Bool
198+
) -> Path {
199+
actions.append(
200+
.arc(
201+
center: center,
202+
radius: radius,
203+
startAngle: startAngle,
204+
endAngle: endAngle,
205+
clockwise: clockwise
206+
)
207+
)
208+
return self
209+
}
210+
211+
public consuming func transform(_ transform: AffineTransform) -> Path {
212+
actions.append(.transform(transform))
213+
return self
214+
}
215+
216+
public consuming func stroke(style: StrokeStyle) -> Path {
217+
strokeStyle = style
218+
return self
219+
}
220+
221+
public consuming func fillRule(_ rule: FillRule) -> Path {
222+
fillRule = rule
223+
return self
224+
}
225+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
public struct Circle: Shape {
2+
public init() {}
3+
4+
public func path(in bounds: Path.Rect) -> Path {
5+
Path().addCircle(
6+
center: SIMD2(x: bounds.x + bounds.width / 2.0, y: bounds.y + bounds.height / 2.0),
7+
radius: min(bounds.width, bounds.height) / 2.0
8+
)
9+
}
10+
11+
public func size(fitting proposal: SIMD2<Int>) -> SIMD2<Int> {
12+
let minDim = min(proposal.x, proposal.y)
13+
return SIMD2(x: minDim, y: minDim)
14+
}
15+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
public struct Rectangle: Shape {
2+
public init() {}
3+
4+
public func path(in bounds: Path.Rect) -> Path {
5+
Path().addRectangle(bounds)
6+
}
7+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
public protocol Shape: View
2+
where Content == EmptyView {
3+
/// Draw the path for this shape.
4+
func path(in bounds: Path.Rect) -> Path
5+
/// Determine the ideal size of this shape given the proposed bounds.
6+
///
7+
/// The default implementation returns the proposal unmodified.
8+
func size(fitting proposal: SIMD2<Int>) -> SIMD2<Int>
9+
}
10+
11+
extension Shape {
12+
public var body: EmptyView { return EmptyView() }
13+
14+
public func size(fitting proposal: SIMD2<Int>) -> SIMD2<Int> {
15+
return proposal
16+
}
17+
18+
public func children<Backend: AppBackend>(
19+
backend _: Backend,
20+
snapshots _: [ViewGraphSnapshotter.NodeSnapshot]?,
21+
environment _: EnvironmentValues
22+
) -> any ViewGraphNodeChildren {
23+
ShapeStorage()
24+
}
25+
26+
public func asWidget<Backend: AppBackend>(
27+
_ children: any ViewGraphNodeChildren, backend: Backend
28+
) -> Backend.Widget {
29+
let container = backend.createContainer()
30+
let storage = children as! ShapeStorage
31+
storage.backendPath = backend.createPath()
32+
storage.oldPath = nil
33+
return container
34+
}
35+
36+
public func update<Backend: AppBackend>(
37+
_ widget: Backend.Widget, children: any ViewGraphNodeChildren, proposedSize: SIMD2<Int>,
38+
environment: EnvironmentValues, backend: Backend, dryRun: Bool
39+
) -> ViewUpdateResult {
40+
let storage = children as! ShapeStorage
41+
let size = size(fitting: proposedSize)
42+
43+
let path = path(in: Path.Rect(x: 0.0, y: 0.0, width: Float(size.x), height: Float(size.y)))
44+
let pointsChanged = storage.oldPath?.actions != path.actions
45+
storage.oldPath = path
46+
47+
let backendPath = storage.backendPath as! Backend.Path
48+
backend.updatePath(backendPath, path, pointsChanged: pointsChanged)
49+
50+
if !dryRun {
51+
backend.setSize(of: widget, to: size)
52+
backend.renderPath(backendPath, container: widget, environment: environment)
53+
}
54+
55+
return ViewUpdateResult.leafView(
56+
size: ViewSize(
57+
size: size,
58+
idealSize: SIMD2(x: 10, y: 10),
59+
minimumWidth: 0,
60+
minimumHeight: 0,
61+
maximumWidth: nil,
62+
maximumHeight: nil
63+
)
64+
)
65+
}
66+
}
67+
68+
final class ShapeStorage: ViewGraphNodeChildren {
69+
let widgets: [AnyWidget] = []
70+
let erasedNodes: [ErasedViewGraphNode] = []
71+
var backendPath: Any!
72+
var oldPath: Path?
73+
}

0 commit comments

Comments
 (0)