Animating a property of a custom Shape in SwiftUI - swift

I have an Arc/Circle shape for which I am updating the end angle in a view.
I defined animatableData for the shape to animate it, however there is no animation happening in the view.
How can I fix this? I want to reach to same result that we get from trim, but without using that modifier.
This is my current code:
struct ContentView: View {
#State private var endAngle: Angle = Angle()
var body: some View {
Button("add") { endAngle += Angle(degrees: 30.0) }.padding()
CircleShape(lineWidth: 10, startAngle: Angle(degrees: 0.0), endAngle: endAngle)
.stroke(Color.green, style: StrokeStyle(lineWidth: 10, lineCap: .round))
.animation(.default, value: endAngle)
}
}
struct CircleShape: Shape {
let lineWidth: CGFloat
var startAngle: Angle
var endAngle: Angle
var animatableData: Angle {
get { endAngle }
set { self.endAngle = newValue }
}
func path(in rect: CGRect) -> Path {
return Path { path in
let radius: CGFloat = (min(rect.maxX, rect.maxY) - lineWidth)/2.0
path.addArc(center: CGPoint(x: rect.midX, y: rect.midY),
radius: radius,
startAngle: startAngle,
endAngle: endAngle,
clockwise: false)
}
}
}

Cedric's solution above works because it substitutes Angle for a value that implements VectorArithmetic. Another solution is to add an extension that makes Angle itself support VectorArithmetic. Here's a Playground that does that:
import UIKit
import SwiftUI
import PlaygroundSupport
extension Angle : AdditiveArithmetic {
public static var zero: Angle {
get { return Angle(degrees: 0)}
}
public static func + (lhs: Angle, rhs: Angle) -> Angle {
return Angle(degrees: fmod(lhs.degrees + rhs.degrees, 360.0))
}
public static func += (lhs: inout Angle, rhs: Angle) {
lhs.degrees = (lhs + rhs).degrees
}
public static func - (lhs: Angle, rhs: Angle) -> Angle {
return Angle(degrees: fmod(lhs.degrees - rhs.degrees, 360.0))
}
public static func -= (lhs: inout Angle, rhs: Angle) {
lhs.degrees = (lhs - rhs).degrees
}
}
extension Angle : VectorArithmetic {
public mutating func scale(by rhs: Double) {
self.degrees = fmod(self.degrees * rhs, 360.0)
}
public var magnitudeSquared: Double {
return self.degrees * self.degrees
}
}
struct ContentView: View {
#State var endAngle: Angle = Angle()
var body: some View {
Button("add") { endAngle += Angle(degrees: 30.0) }.padding()
CircleShape(lineWidth: 10, startAngle: Angle(degrees: 0.0), endAngle: endAngle)
.stroke(Color.green, style: StrokeStyle(lineWidth: 10, lineCap: .round))
.animation(.default, value: endAngle)
}
}
struct CircleShape: Shape {
let lineWidth: CGFloat
var startAngle: Angle
var endAngle: Angle
var animatableData: Angle {
get { endAngle }
set { self.endAngle = newValue }
}
func path(in rect: CGRect) -> Path {
return Path { path in
let radius: CGFloat = (min(rect.maxX, rect.maxY) - lineWidth)/2.0
path.addArc(center: CGPoint(x: rect.midX, y: rect.midY),
radius: radius,
startAngle: startAngle,
endAngle: endAngle,
clockwise: false)
}
}
}
let hostingController = UIHostingController(rootView: ContentView())
hostingController.view.bounds = CGRect(x: 0,y: 0,width: 320,height: 480)
// Present the view controller in the Live View window
PlaygroundPage.current.liveView = hostingController

The problem seems to be the use of Angle.
One of the simplest solutions is to use the CGFloat type which conforms VectorArithmetic.
So changing Angle to CGFloat, your code would look like:
struct ContentView: View {
#State private var endAngle: CGFloat = 0.0
var body: some View {
VStack {
Button("add") { endAngle += 30.0 }.padding()
CircleShape(lineWidth: 10, startAngle: 0, endAngle: endAngle)
.stroke(Color.green, style: StrokeStyle(lineWidth: 10, lineCap: .round))
.animation(.default, value: endAngle)
}
.padding()
}
}
struct CircleShape: Shape {
let lineWidth: CGFloat
var startAngle: CGFloat
var endAngle: CGFloat
var animatableData: CGFloat {
get { endAngle }
set { endAngle = newValue }
}
func path(in rect: CGRect) -> Path {
return Path { path in
let radius: CGFloat = (min(rect.maxX, rect.maxY) - lineWidth)/2.0
path.addArc(center: CGPoint(x: rect.midX, y: rect.midY),
radius: radius,
startAngle: Angle(degrees: startAngle),
endAngle: Angle(degrees: endAngle),
clockwise: false)
}
}
}

Angle already Conforms to Animatable, so all you have to do is to modify your shape's animatableData property:
var animatableData: Angle.AnimatableData {
get { endAngle.animatableData }
set { endAngle.animatableData = newValue }
}
Bonus: if you want to animate both start and end angles:
var animatableData: AnimatablePair<Angle.AnimatableData, Angle.AnimatableData> {
get { AnimatableData(startAngle.animatableData, endAngle.animatableData) }
set { startAngle.animatableData = newValue.first; endAngle.animatableData = newValue.second }
}

Related

Unable to change the value of a #State variable in swift?

Swift 5.x iOS 15
Just playing around with an idea and hit a roadblock? Why can I not change the value of this state variable in this code?
struct ContentView: View {
let timer = Timer.publish(every: 0.25, on: .main, in: .common).autoconnect()
#State var rexShape = CGRect(x: 0, y: 0, width: 128, height: 128)
var body: some View {
Arc(startAngle: .degrees(0), endAngle: .degrees(180), clockwise: true)
.stroke(Color.red, lineWidth: 2)
.frame(width: 128, height: 128, alignment: .center)
.onReceive(timer) { _ in
rexShape.height -= 10
}
}
}
struct Arc: Shape {
var startAngle: Angle
var endAngle: Angle
var clockwise: Bool
func path(in rect: CGRect) -> Path {
var path = Path()
path.addArc(center: CGPoint(x: rect.midX, y: rect.midY), radius: rect.width / 2, startAngle: startAngle, endAngle: endAngle, clockwise: clockwise)
return path
}
}
The line rexShape.height is telling me height is a get-only property? which makes no sense to me cause rexShape should be a variable? Am I simply losing my mind...
If you pay close attention to the error message, the compiler isn't complaining about rexShape not being mutable, but about CGRect.height not being mutable.
To change the only height or weight of a CGRect, you need to do it via its size property.
rexShape.size.height -= 10

Add filled circles (markers) at SwiftUI path points

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:

Draw Shape over another Shape

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:

Can't use 'shape' as Type in SwiftUI

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).

Simple SwiftUI Arc endAngle animation not working, only jump from one state to another

I'm trying to make a simple animation of the appearance of the arc.
This is very simple, and I don't have any idea why is not working.
I make this struct
struct Arc: Shape {
var center: CGPoint
var radius: CGFloat
var endAngle: Double
func path(in rect: CGRect) -> Path {
var path = Path()
path.addArc(center: center, radius: radius, startAngle: .degrees(180), endAngle: .degrees(endAngle), clockwise: false)
return path
}
}
And after that load it like this
struct TestView: View {
#State var endAngle: Double = 180
var body: some View {
Arc(center: CGPoint(x: 250, y: 250), radius: 100, endAngle: self.endAngle)
.stroke(Color.orange, lineWidth: 5)
.onAppear() {
withAnimation(Animation.linear(duration: 20)) {
self.endAngle = 0
}
}
}
}
But is not animate, only jump from 180 to 0.
I try OnTapGesture too, but also dosn't work.
I don't now, why this dosn't work
My ContentView is simple
struct ContentView: View {
var body: some View {
TestView()
}
}
How can I fix it?
Just add animatable data to your Arc, to indicate which parameter should be animated, as below
Tested with Xcode 11.4 / iOS 13.4
struct Arc: Shape {
var center: CGPoint
var radius: CGFloat
var endAngle: Double
var animatableData: CGFloat { // << here !!
get { CGFloat(endAngle) }
set { endAngle = Double(newValue) }
}
func path(in rect: CGRect) -> Path {
var path = Path()
path.addArc(center: center, radius: radius, startAngle: .degrees(180), endAngle: .degrees(endAngle), clockwise: false)
return path
}
}