I'm trying to draw a line with markers at each point by using a Shape view in SwiftUI. I want the line to have a filled circle at each CGPoint on the line. The closest to this that I've gotten is by adding an arc at each point. Instead of using the arc, how can I add a Circle() shape at each point? I'm open to other approaches to accomplish this. My only requirements are to use SwiftUI and have the ability to interact with the markers.
import SwiftUI
struct LineShape: Shape {
let values: [Double]
func path(in rect: CGRect) -> Path {
let xStep = rect.width / CGFloat(values.count - 1)
var path = Path()
path.move(to: CGPoint(x: 0.0, y: (1 - values[0]) * Double(rect.height)))
for i in 1..<values.count {
let pt = CGPoint(x: Double(i) * Double(xStep), y: (1 - values[i]) * Double(rect.height))
path.addLine(to: pt)
path.addArc(center: pt, radius: 8, startAngle: Angle(degrees: 0), endAngle: Angle(degrees: 360), clockwise: false)
}
return path
}
}
struct ContentView: View {
var body: some View {
LineShape(values: [0.2, 0.4, 0.3, 0.8, 0.5])
.stroke(.red, lineWidth: 2.0)
.padding()
.frame(width: 400, height: 300)
}
}
You can make two different shapes, one for the line and one for the markers, and overlay them. Then you can also control their coloring individually:
struct LineShape: Shape {
let values: [Double]
func path(in rect: CGRect) -> Path {
let xStep = rect.width / CGFloat(values.count - 1)
var path = Path()
path.move(to: CGPoint(x: 0.0, y: (1 - values[0]) * Double(rect.height)))
for i in 1..<values.count {
let pt = CGPoint(x: Double(i) * Double(xStep), y: (1 - values[i]) * Double(rect.height))
path.addLine(to: pt)
}
return path
}
}
struct MarkersShape: Shape {
let values: [Double]
func path(in rect: CGRect) -> Path {
let xStep = rect.width / CGFloat(values.count - 1)
var path = Path()
for i in 1..<values.count {
let pt = CGPoint(x: Double(i) * Double(xStep), y: (1 - values[i]) * Double(rect.height))
path.addEllipse(in: CGRect(x: pt.x - 8, y: pt.y - 8, width: 16, height: 16))
}
return path
}
}
struct ContentView: View {
var body: some View {
LineShape(values: [0.2, 0.4, 0.3, 0.8, 0.5])
.stroke(.red, lineWidth: 2.0)
.overlay(
MarkersShape(values: [0.2, 0.4, 0.3, 0.8, 0.5])
.fill(.blue)
)
.frame(width: 350, height: 300)
}
}
According to previous posts and comments here is my solution:
First there is need to use extension for filling and setting shape stroke:
extension Shape {
public func fill<Shape: ShapeStyle>(_ fillContent: Shape, strokeColor: Color, lineWidth: CGFloat) -> some View {
ZStack {
self.fill(fillContent)
self.stroke(strokeColor, lineWidth: lineWidth)
}
}
}
Another thing is Scaler struct used to make scaling calculations:
struct Scaler {
let bounds: CGPoint
let maxVal: Double
let minVal: Double
let valuesCount: Int
var xFactor: CGFloat {
valuesCount < 2 ? bounds.x : bounds.x / CGFloat(valuesCount - 1)
}
var yFactor: CGFloat {
bounds.y / maxVal
}
}
Now it's time for real chart shape drawing:
struct ChartLineDotsShape: Shape {
let values: [Double]
let dotRadius: CGFloat
func path(in rect: CGRect) -> Path {
guard let maxVal = values.max(), let minVal = values.min() else { return Path() }
let scaler = Scaler(bounds: CGPoint(x: rect.width, y: rect.height), maxVal: maxVal, minVal: minVal, valuesCount: values.count)
return Path { path in
var valuesIterator = values.makeIterator()
let dx = scaler.xFactor
var x = 0.0
guard let y = valuesIterator.next() else { return }
path.move(to: CGPoint(x: x, y: calculate(y, scaler: scaler)))
draw(point: CGPoint(x: x, y: calculate(y, scaler: scaler)), on: &path)
x += dx
while let y = valuesIterator.next() {
draw(point: CGPoint(x: x, y: calculate(y, scaler: scaler)), on: &path)
x += dx
}
}
}
private func calculate(_ value: CGFloat, scaler: Scaler) -> CGFloat {
scaler.bounds.y - value * scaler.yFactor
}
private func draw(point: CGPoint, on path: inout Path) {
path.addLine(to: CGPoint(x: point.x, y: point.y))
path.addEllipse(in: CGRect(x: point.x - dotRadius * 0.5, y: point.y - dotRadius * 0.5, width: dotRadius, height: dotRadius))
path.move(to: CGPoint(x: point.x, y: point.y))
}
}
If there is single iteration loop lines dots drawing shape we can try to use it in some view:
public struct ChartView: View {
public var body: some View {
ChartLineDotsShape(values: [0.3, 0.5, 1.0, 2.0, 2.5, 2.0, 3.0, 6.0, 2.0, 1.5, 2.0], dotRadius: 4.0)
.fill(Color.blue, strokeColor: Color.blue, lineWidth: 1.0)
.shadow(color: Color.blue, radius: 2.0, x: 0.0, y: 0.0)
.frame(width: 320.0, height: 200.0)
.background(Color.blue.opacity(0.1))
.clipped()
}
}
Finally preview setup:
struct ChartView_Previews: PreviewProvider {
static var previews: some View {
ChartView()
.preferredColorScheme(.dark)
}
}
and viola:
Related
I want to add a bottom gradient in my line graph like in the picture. I have the lines plotted out but I am not sure how to actually add the gradient. I was able to do this in UIKit but am not sure how to replicate it for SwiftUI.
SwiftUI Line Plot View:
var endingBalanceChart: some View {
GeometryReader { geometry in
Path { path in
for index in viewModel.endingBalance.indices {
let xPosition: CGFloat = geometry.size.width / CGFloat(viewModel.endingBalance.count) * CGFloat(index + 1)
let yAxis: CGFloat = maxY - minY
let maxYPosition: CGFloat = (1 - CGFloat((Double(viewModel.endingBalance[index].y) - minY) / yAxis)) * geometry.size.height
let yPosition: CGFloat = index == 0 ? 200 : maxYPosition
if index == 0 {
path.move(to: CGPoint(x: 0, y: yPosition))
}
path.addLine(to: CGPoint(x: xPosition, y: yPosition))
}
}
.stroke(Color.blue, style: StrokeStyle(lineWidth: 1, lineCap: .round, lineJoin: .round))
}
}
I tried converting the path in to a UIBezierPath and trying my UIKit implementation but no luck
This was my UIKit implementation:
func addGradient(path: UIBezierPath, hexString: String){
let color = UIColor(hexString: hexString).withAlphaComponent(0.4).cgColor
guard let clippingPath = path.copy() as? UIBezierPath else { return }
clippingPath.addLine(to: CGPoint(x: self.bounds.width, y: self.bounds.height))
clippingPath.addLine(to: CGPoint(x: 0, y: bounds.height))
clippingPath.close()
clippingPath.addClip()
let colors = [color, UIColor.clear.cgColor]
let colorSpace = CGColorSpaceCreateDeviceRGB()
let colorLocations: [CGFloat] = [0.0, 1.0]
guard let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: colorLocations) else { return }
guard let context = UIGraphicsGetCurrentContext() else { return }
let startPoint = CGPoint(x: 1, y: 1)
let endPoint = CGPoint(x: 1, y: bounds.maxY)
context.drawLinearGradient(gradient, start: startPoint, end: endPoint, options: .drawsAfterEndLocation)
}
Here would be a pure SwiftUI implementation.
I suggest to convert the var into an own struct that conforms to Shape protocol. Then you can use it for both stroke and fill background.
This has the positive side effect that you don't need a GeometryReader any more, as Shape provides you with the drawing rectangle with func path(in rect: CGRect).
The result looks like this:
let endingBalance: [Double] = [0, 1, 2, 4, 7, 11, 16, 22, 29, 37, 46, 56] // dummy data
struct ContentView: View {
var body: some View {
VStack {
Text("Chart")
EndingBalanceChart()
.stroke(Color.blue, style: StrokeStyle(lineWidth: 1, lineCap: .round, lineJoin: .round)) // line
.background(
EndingBalanceChart(isBackground: true)
.fill(.linearGradient(colors: [.cyan, .clear], startPoint: .top, endPoint: .bottom)) // background fill
)
.frame(height: 200)
.padding()
}
}
}
struct EndingBalanceChart: Shape { // chnaged var to a Shape struct
var isBackground: Bool = false
func path(in rect: CGRect) -> Path {
Path { path in
for index in endingBalance.indices {
let xPosition: CGFloat = rect.width / CGFloat(endingBalance.count) * CGFloat(index + 1)
let maxY = endingBalance.max() ?? 0
let minY = endingBalance.min() ?? 0
let yAxis: CGFloat = maxY - minY
let yPosition: CGFloat = (1 - CGFloat((Double(endingBalance[index]) - minY) / yAxis)) * rect.height
if index == 0 {
path.move(to: CGPoint(x: 0, y: rect.height))
}
path.addLine(to: CGPoint(x: xPosition, y: yPosition))
}
if isBackground { // this is needed so the backkground shape is filled correctly (closing the shape)
path.addLine(to: CGPoint(x: rect.width, y: rect.height))
}
}
}
}
Here is an approach:
let amountPaid: [Double] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] // dummy data
let endingBalance: [Double] = [0, 1, 2, 4, 7, 11, 16, 22, 29, 37, 46, 56] // dummy data
struct ContentView: View {
#State private var value: Double = 0
var body: some View {
VStack {
Text("Chart")
CurveChart(data: endingBalance)
.stroke(Color.blue, style: StrokeStyle(lineWidth: 1, lineCap: .round, lineJoin: .round)) // line
.background(
CurveChart(data: endingBalance, isBackground: true)
.fill(.linearGradient(colors: [.cyan, .clear], startPoint: .top, endPoint: .bottom)) // background fill
)
.frame(height: 200)
.overlay(alignment: .topTrailing, content: {
plotPoint(data: endingBalance, index: Int(value))
.fill(.blue)
})
CurveChart(data: amountPaid)
.stroke(Color.blue, style: StrokeStyle(lineWidth: 1, lineCap: .round, lineJoin: .round)) // line
.background(
CurveChart(data: amountPaid, isBackground: true)
.fill(.linearGradient(colors: [.green, .clear], startPoint: .top, endPoint: .bottom)) // background fill
)
.frame(height: 60)
.overlay(alignment: .topTrailing, content: {
plotPoint(data: amountPaid, index: Int(value))
.fill(.green)
})
.padding(.bottom)
Slider(value: $value, in: 0...Double(endingBalance.count-1), step: 1.0)
}
.padding()
}
}
struct CurveChart: Shape { // chnaged var to a Shape struct
let data: [Double]
var isBackground: Bool = false
func path(in rect: CGRect) -> Path {
Path { path in
for index in data.indices {
let xPosition: CGFloat = rect.width / CGFloat(data.count-1) * CGFloat(index)
let maxY = data.max() ?? 0
let minY = data.min() ?? 0
let yAxis: CGFloat = maxY - minY
let yPosition: CGFloat = (1 - CGFloat((Double(data[index]) - minY) / yAxis)) * rect.height
if index == 0 {
path.move(to: CGPoint(x: 0, y: rect.height))
}
path.addLine(to: CGPoint(x: xPosition, y: yPosition))
}
if isBackground { // this is needed so the backkground shape is filled correctly (closing the shape)
path.addLine(to: CGPoint(x: rect.width, y: rect.height))
}
}
}
}
struct plotPoint: Shape { // chnaged var to a Shape struct
let data: [Double]
let index: Int
let size = 20.0
func path(in rect: CGRect) -> Path {
let xStep = rect.width / Double(data.count-1)
let yStep = rect.height / (data.max() ?? 0)
let xCenter = Double(index) * xStep
let yCenter = rect.height - yStep * data[index]
var path = Path()
path.addEllipse(in: CGRect(x: xCenter - size/2, y: yCenter - size/2, width: size, height: size))
return path
}
}
I have just recently started using Swiftui and have the following question:
I would like to convert the current time to an analog view. But when I use the code below, I always get an error message at the angle. What is the reason that it does not work?
struct Hand: Shape {
let inset: CGFloat
let angle: Angle
func path(in rect: CGRect) -> Path {
let rect = rect.insetBy(dx: inset, dy: inset)
var path = Path()
path.move(to: CGPoint(x: rect.midX, y: rect.midY))
path.addRoundedRect(in: CGRect(x: rect.midX - 4, y: rect.midY - 4, width: 8, height: 8), cornerSize: CGSize(width: 8, height: 8))
path.move(to: CGPoint(x: rect.midX, y: rect.midY))
path.addLine(to: position(for: CGFloat(angle.radians), in: rect))
return path
}
private func position(for angle: CGFloat, in rect: CGRect) -> CGPoint {
let angle = angle - (.pi/2)
let radius = min(rect.width, rect.height)/2
let xPosition = rect.midX + (radius * cos(angle))
let yPosition = rect.midY + (radius * sin(angle))
return CGPoint(x: xPosition, y: yPosition)
}
}
struct TickHands: View {
#State private var currentDate = Date()
let timer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()
var body: some View {
ZStack {
Hand(inset: 50, angle: currentDate.hourAngle)
.stroke(lineWidth: 4)
.foregroundColor(.black)
Hand(inset: 22, angle: currentDate.minuteAngle)
.stroke(lineWidth: 4)
.foregroundColor(.black)
Hand(inset: 10, angle: currentDate.secondAngle)
.stroke(lineWidth: 2)
.foregroundColor(.gray)
}
.onReceive(timer) { (input) in
self.currentDate = input
}}}
Based on the link you provided, It looks like the author of the article did not provide some extensions.
Add the following extensions to your code (preferable in a new file), and you should be good 🚀:
extension Date {
var hourAngle: Angle {
return Angle (degrees: (360 / 12) * (self.hour + self.minutes / 60))
}
var minuteAngle: Angle {
return Angle(degrees: (self.minutes * 360 / 60))
}
var secondAngle: Angle {
return Angle (degrees: (self.seconds * 360 / 60))
}
}
extension Date {
var hour: Double {
return Double(Calendar.current.component(.hour, from: self))
}
var minutes: Double {
return Double(Calendar.current.component(.minute, from: self))
}
var seconds: Double {
return Double(Calendar.current.component(.second, from: self))
}
}
Problem :
I cannot draw a shape on another shape.
What I am trying to achieve :
Draw circles on the line.
Anyway, the circle is shifting the line. I didn't find a way to make it as swift UI seems relatively new. I am currently learning swift and I prefer swift UI rater than storyboard.
If circle and line are different struct, this is because I want to reuse the shape later on.
There is the code :
import SwiftUI
public var PointArray = [CGPoint]()
public var PointArrayInit:Bool = false
struct Arc: Shape {
var startAngle: Angle
var endAngle: Angle
var clockwise: Bool
var centerCustom:CGPoint
func path(in rect: CGRect) -> Path {
let rotationAdjustment = Angle.degrees(90)
let modifiedStart = startAngle - rotationAdjustment
let modifiedEnd = endAngle - rotationAdjustment
var path = Path()
path.addArc(center: CGPoint(x: rect.midX, y: rect.midY), radius: 20, startAngle: modifiedStart, endAngle: modifiedEnd, clockwise: !clockwise)
return path
}
}
struct CurveCustomInit: Shape {
private var Divider:Int = 10
func path(in rect: CGRect) -> Path {
var path = Path()
let xStep:CGFloat = DrawingZoneWidth / CGFloat(Divider)
let yStep:CGFloat = DrawingZoneHeight / 2
var xStepLoopIncrement:CGFloat = 0
path.move(to: CGPoint(x: 0, y: yStep))
for _ in 0...Divider {
let Point:CGPoint = CGPoint(x: xStepLoopIncrement, y: yStep)
PointArray.append(Point)
path.addLine(to: Point)
xStepLoopIncrement += xStep
}
PointArrayInit = true
return (path)
}
}
struct TouchCurveBasic: View {
var body: some View {
if !PointArrayInit {
Arc(startAngle: .degrees(0), endAngle: .degrees(360), clockwise: true, centerCustom: CGPoint(x: 50, y: 400))
.stroke(Color.blue, lineWidth: 4)
CurveCustomInit()
.stroke(Color.red, style: StrokeStyle(lineWidth: 10, lineCap: .round, lineJoin: .round))
.frame(width: 300, height: 300)
} else {
}
}
}
struct TouchCurveBasic_Previews: PreviewProvider {
static var previews: some View {
TouchCurveBasic()
}
}
There is what I get :
Here is an other way for you, you can limit the size of drawing with giving a frame or you can use the available size of view without limiting it or even you can use the current limit coming from parent and updated it, like i did on drawing the Line. The method that I used was overlay modifier.
struct ContentView: View {
var body: some View {
ArcView(radius: 30.0)
.stroke(lineWidth: 10)
.foregroundColor(.blue)
.frame(width: 60, height: 60)
.overlay(LineView().stroke(lineWidth: 10).foregroundColor(.red).frame(width: 400))
}
}
struct ArcView: Shape {
let radius: CGFloat
func path(in rect: CGRect) -> Path {
return Path { path in
path.addArc(center: CGPoint(x: rect.midX, y: rect.midY), radius: radius, startAngle: Angle(degrees: 0.0), endAngle: Angle(degrees: 360.0), clockwise: true)
}
}
}
struct LineView: Shape {
func path(in rect: CGRect) -> Path {
return Path { path in
path.move(to: CGPoint(x: rect.minX, y: rect.midY))
path.addLines([CGPoint(x: rect.minX, y: rect.midY), CGPoint(x: rect.maxX, y: rect.midY)])
}
}
}
result:
I'm trying to create a Set of cards. Each card has an object (shape) assigned, that is then shown with a different colour and appears in different numbers on each card. -> as a template I've created the struct SetCard
Already within the definition of the struct SetCard the var shape: Shape returns the error message:
Value of protocol type 'Shape' cannot conform to 'View'; only struct/enum/class types can conform to protocols"
import Foundation
import SwiftUI
var setCardSet: Array<SetCard> = []
var counter: Int = 0
func createSet() -> Array<SetCard> {
for object in 0..<3 {
var chosenShape = objectLibrary[object]
for color in 0..<3 {
var chosenColor = colors[color]
for numberOfShapes in 0..<3 {
counter += 1
setCardSet.append(SetCard(id: counter, shape: chosenShape, numberOfShapes: numberOfShapes, colorOfShapes: chosenColor))
}
}
}
return setCardSet
}
struct SetCard: Identifiable {
var id: Int
var shape: Shape
var numberOfShapes: Int
var colorOfShapes: Color
// var shadingOfShapes: Double
}
struct GameObject {
var name: String
var object: Shape
}
let objectLibrary: [Shape] = [Diamond(), Oval()]
let colors: [Color] = [Color.green, Color.red, Color.purple]
At a later step, the individual objects are shown and "stacked" on the same card:
import SwiftUI
struct ContentView: View {
let observed: Array<SetCard> = createSet()
var body: some View {
VStack (observed) { card in
CardView(card: card).onTapGesture {
withAnimation(.linear(duration: 0.75)) {
print(card.id)
}
}
.padding(2)
}
}
}
struct CardView: View {
var card: SetCard
var body: some View {
ZStack {
VStack{
ForEach(0 ..< card.numberOfShapes+1) {_ in
card.shape
}
}
}
}
}
let gradientStart = Color(red: 239.0 / 255, green: 120.0 / 255, blue: 221.0 / 255)
let gradientEnd = Color(red: 239.0 / 255, green: 172.0 / 255, blue: 120.0 / 255)
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
What am I doing wrong here?
Definition of shapes:
import SwiftUI
struct Diamond: Shape {
func path(in rect: CGRect) -> Path {
let height = min(rect.width, rect.height)/4
let length = min(rect.width, rect.height)/3
let center = CGPoint(x: rect.midX, y: rect.midY)
let top: CGPoint = CGPoint(x: center.x + length, y: center.y)
let left: CGPoint = CGPoint(x: center.x, y: center.y - height)
let bottom: CGPoint = CGPoint(x: center.x - length, y: center.y)
let right: CGPoint = CGPoint(x: center.x, y: center.y + height)
var p = Path()
p.move(to: top)
p.addLine(to: left)
p.addLine(to: bottom)
p.addLine(to: right)
p.addLine(to: top)
return p
}
}
struct Oval: Shape {
func path(in rect: CGRect) -> Path {
let height = min(rect.width, rect.height)/4
let length = min(rect.width, rect.height)/3
let center = CGPoint(x: rect.midX, y: rect.midY)
let centerLeft = CGPoint(x: center.x - length + (height/2), y: center.y)
let centerRight = CGPoint(x: center.x + length - (height/2), y: center.y)
let bottomRight = CGPoint(x: centerRight.x, y: center.y - height)
let topLeft = CGPoint(x: centerLeft.x, y: center.y + height)
var p = Path()
p.move(to: topLeft)
p.addArc(center: centerLeft, radius: height, startAngle: Angle(degrees: 90), endAngle: Angle(degrees: 270), clockwise: false)
p.addLine(to: bottomRight)
p.addArc(center: centerRight, radius: height, startAngle: Angle(degrees: 270), endAngle: Angle(degrees: 90), clockwise: false)
p.addLine(to: topLeft)
return p
}
}
You need to use one type for shape in your model, like
struct SetCard: Identifiable {
var id: Int
var shape: AnyShape // << here !!
var numberOfShapes: Int
var colorOfShapes: Color
// var shadingOfShapes: Double
}
The AnyShape declaration and demo of usage can be observed in my different answer https://stackoverflow.com/a/62605936/12299030
And, of course, you have to update all other dependent parts to use it (I skipped that for simplicity).
From a file I have a bunch of SVG paths that I convert to UIBezierPath. In order to make my example simple I create the path manually:
struct myShape: Shape {
func path(in rect: CGRect) -> Path {
let p = UIBezierPath()
p.move(to: CGPoint(x: 147, y: 32))
p.addQuadCurve(to: CGPoint(x: 203, y: 102), controlPoint: CGPoint(x: 181, y: 74))
p.addQuadCurve(to: CGPoint(x: 271, y: 189), controlPoint: CGPoint(x: 242, y: 166))
p.addQuadCurve(to: CGPoint(x: 274, y: 217), controlPoint: CGPoint(x: 287, y: 204))
p.addQuadCurve(to: CGPoint(x: 229, y: 235), controlPoint: CGPoint(x: 258, y: 229))
p.addQuadCurve(to: CGPoint(x: 193, y: 235), controlPoint: CGPoint(x: 204, y: 241))
p.addQuadCurve(to: CGPoint(x: 190, y: 219), controlPoint: CGPoint(x: 183, y: 231))
p.addQuadCurve(to: CGPoint(x: 143, y: 71), controlPoint: CGPoint(x: 199, y: 195))
p.addQuadCurve(to: CGPoint(x: 125, y: 33), controlPoint: CGPoint(x: 134, y: 55))
p.addCurve(to: CGPoint(x: 147, y: 32), controlPoint1: CGPoint(x: 113, y: 5), controlPoint2: CGPoint(x: 128, y: 9))
p.close()
return Path(p.cgPath)
}
}
The result is the following figure:
For this figure I have a separated path/shape that represents the "median" of the figure and the order in which this figure should be filled. The path is just a concatenation of lines.
struct myMedian: Shape {
func path(in rect: CGRect) -> Path {
let p = UIBezierPath()
p.move(to: CGPoint(x: 196, y: 226))
p.addLine(to: CGPoint(x: 209, y: 220))
p.addLine(to: CGPoint(x: 226, y: 195))
p.addLine(to: CGPoint(x: 170, y: 86))
p.addLine(to: CGPoint(x: 142, y: 43))
p.addLine(to: CGPoint(x: 131, y: 39))
return Path(p.cgPath)
}
}
To visualize the order of the lines I've added the red arrows:
Now I need to fill the "big figure" in the same order as the "median stroke". I know how to fill the whole figure in one step, but not splitwise and especially I don't know how to manage the direction of the animation.
The final result should look like this:
Since I'm using SwiftUI it should be compatible with it.
The main view is:
struct DrawCharacter: View {
var body: some View {
ZStack(alignment: .topLeading){
myShape()
myMedian()
}
}
}
You can define an animatableData property in your Shape to enable SwiftUI to interpolate between states (e.g., filled and unfilled). Where you start drawing each stroke will matter for direction. You could also use .trim on the path to truncate it if you prefer and tie that value into animatableData.
For a whole character/kanji, you may need to compose a meta Shape or View of multiple sub-Shapes with defined positions, but that's actually less work for you in the long run because you can create a library of strokes that are easy to recombine, no?
In the example below, I move a moon from full to crescent by changing the percentFullMoon property from other views. That property is used by the path drawing function to setup some arcs. SwiftUI reads the animatableData property to figure out how to draw however many frames it chooses to interpolate at the time of animation.
It's rather simple, even if this API is obscurely named.
Does that make sense? Note: the code below uses some helper functions like clamp, north/south, and center/radius, but are irrelevant to the concept.
import SwiftUI
/// Percent full moon is from 0 to 1
struct WaningMoon: Shape {
/// From 0 to 1
var percentFullMoon: Double
var animatableData: Double {
get { percentFullMoon }
set { self.percentFullMoon = newValue }
}
func path(in rect: CGRect) -> Path {
var path = Path()
addExteriorArc(&path, rect)
let cycle = percentFullMoon * 180
switch cycle {
case 90:
return path
case ..<90:
let crescent = Angle(degrees: 90 + .clamp(0.nextUp, 90, 90 - cycle))
addInteriorArc(&path, rect, angle: crescent)
return path
case 90.nextUp...:
let gibbous = Angle(degrees: .clamp(0, 90.nextDown, 180 - cycle))
addInteriorArc(&path, rect, angle: gibbous)
return path
default: return path
}
}
private func addInteriorArc(_ path: inout Path, _ rect: CGRect, angle: Angle) {
let xOffset = rect.radius * angle.tan()
let offsetCenter = CGPoint(x: rect.midX - xOffset, y: rect.midY)
return path.addArc(
center: offsetCenter,
radius: rect.radius / angle.cos(),
startAngle: .south() - angle,
endAngle: .north() + angle,
clockwise: angle.degrees < 90) // False == Crescent, True == Gibbous
}
private func addExteriorArc(_ path: inout Path, _ rect: CGRect) {
path.addArc(center: rect.center,
radius: rect.radius,
startAngle: .north(),
endAngle: .south(),
clockwise: true)
}
}
Helpers
extension Comparable {
static func clamp<N:Comparable>(_ min: N, _ max: N, _ variable: N) -> N {
Swift.max(min, Swift.min(variable, max))
}
func clamp<N:Comparable>(_ min: N, _ max: N, _ variable: N) -> N {
Swift.max(min, Swift.min(variable, max))
}
}
extension Angle {
func sin() -> CGFloat {
CoreGraphics.sin(CGFloat(self.radians))
}
func cos() -> CGFloat {
CoreGraphics.cos(CGFloat(self.radians))
}
func tan() -> CGFloat {
CoreGraphics.tan(CGFloat(self.radians))
}
static func north() -> Angle {
Angle(degrees: -90)
}
static func south() -> Angle {
Angle(degrees: 90)
}
}
extension CGRect {
var center: CGPoint {
CGPoint(x: midX, y: midY)
}
var radius: CGFloat {
min(width, height) / 2
}
var diameter: CGFloat {
min(width, height)
}
var N: CGPoint { CGPoint(x: midX, y: minY) }
var E: CGPoint { CGPoint(x: minX, y: midY) }
var W: CGPoint { CGPoint(x: maxX, y: midY) }
var S: CGPoint { CGPoint(x: midX, y: maxY) }
var NE: CGPoint { CGPoint(x: maxX, y: minY) }
var NW: CGPoint { CGPoint(x: minX, y: minY) }
var SE: CGPoint { CGPoint(x: maxX, y: maxY) }
var SW: CGPoint { CGPoint(x: minX, y: maxY) }
func insetN(_ denominator: CGFloat) -> CGPoint {
CGPoint(x: midX, y: minY + height / denominator)
}
func insetE(_ denominator: CGFloat) -> CGPoint {
CGPoint(x: minX - width / denominator, y: midY)
}
func insetW(_ denominator: CGFloat) -> CGPoint {
CGPoint(x: maxX + width / denominator, y: midY)
}
func insetS(_ denominator: CGFloat) -> CGPoint {
CGPoint(x: midX, y: maxY - height / denominator)
}
func insetNE(_ denominator: CGFloat) -> CGPoint {
CGPoint(x: maxX + width / denominator, y: minY + height / denominator)
}
func insetNW(_ denominator: CGFloat) -> CGPoint {
CGPoint(x: minX - width / denominator, y: minY + height / denominator)
}
func insetSE(_ denominator: CGFloat) -> CGPoint {
CGPoint(x: maxX + width / denominator, y: maxY - height / denominator)
}
func insetSW(_ denominator: CGFloat) -> CGPoint {
CGPoint(x: minX - width / denominator, y: maxY - height / denominator)
}
}