Unable to change the value of a #State variable in swift? - 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

Related

Animating a property of a custom Shape in SwiftUI

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 }
}

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:

How to fill a path with a radial gradient in SwiftUI

I'm trying to fill a path with a radial gradient, but no matter what I try, it always ends up being one solid color (as in the image below). It should transition from red to blue.
How do you fill a path with a radial gradient in SwiftUI? What am I doing wrong?
Thank you!
struct ContentView: View {
var body: some View {
Rectangle()
.foregroundColor(Color.black)
Path { path in
path.addArc(center: CGPoint(x: 200, y: 200), radius: 100, startAngle: .degrees(0), endAngle: .degrees(360), clockwise: true)
}
.fill(RadialGradient(
gradient: Gradient(colors: [.red, .blue]),
center: UnitPoint(x: 200.0, y: 200),
startRadius: 20.0,
endRadius: 100.0
))
.edgesIgnoringSafeArea(.all)
}
}
As it turns out, UnitPoint's x and y are values between 0 and 1. Some simple math solved the problem.
This works:
struct ContentView: View {
private let x: Double = 200.0
private let y: Double = 200.0
private let radius: CGFloat = 100.0
var body: some View {
GeometryReader { geo in
Rectangle()
.foregroundColor(Color.black)
Path { path in
path.addArc(center: CGPoint(x: self.x, y: self.y), radius: self.radius, startAngle: .degrees(0), endAngle: .degrees(360), clockwise: true)
}
.fill(RadialGradient(
gradient: Gradient(colors: [.red, .blue]),
center: UnitPoint(
x: CGFloat(self.x / Double(geo.size.width)),
y: CGFloat(self.y / Double(geo.size.height))
),
startRadius: self.radius * 0.30,
endRadius: self.radius
))
}
}
}

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
}
}

How to Animate Path in SwiftUI

Being unfamiliar with SwiftUI and the fact that there is not much documentation on this new framework yet. I was wondering if anyone was familiar with how it would be possible to animate a Path in SwiftUI.
For example given a view, lets say this simple RingView:
struct RingView : View {
var body: some View {
GeometryReader { geometry in
Group {
// create outer ring path
Path { path in
path.addArc(center: center,
radius: outerRadius,
startAngle: Angle(degrees: 0),
endAngle: Angle(degrees: 360),
clockwise: true)
}
.stroke(Color.blue)
// create inner ring
Path { path in
path.addArc(center: center,
radius: outerRadius,
startAngle: Angle(degrees: 0),
endAngle: Angle(degrees: 180),
clockwise: true)
}
.stroke(Color.red)
.animation(.basic(duration: 2, curve: .linear))
}
}
.aspectRatio(1, contentMode: .fit)
}
}
What is displayed is:
Now I was wondering how we could go about animating the inner ring, that is the red line inside the blue line. The animation I'm looking to do would be a simple animation where the path appears from the start and traverses to the end.
This is rather simple using CoreGraphics and the old UIKit framework but it doesn't seem like adding a simple .animation(.basic(duration: 2, curve: .linear)) to the inner path and displaying the view with a withAnimation block does anything.
I've looked at the provided Apple tutorials on SwiftUI but they really only cover move/scale animations on more in-depth views such as Image.
Any guidance on how to animate a Path or Shape in SwiftUI?
Animation of paths is showcased in the WWDC session 237 (Building Custom Views with SwiftUI). The key is using AnimatableData. You can jump ahead to 31:23, but I recommend you start at least at minute 27:47.
You will also need to download the sample code, because conveniently, the interesting bits are not shown (nor explained) in the presentation: https://developer.apple.com/documentation/swiftui/drawing_and_animation/building_custom_views_in_swiftui
More documentation:
Since I originally posted the answer, I continued to investigate how to animate Paths and posted an article with an extensive explanation of the Animatable protocol and how to use it with Paths: https://swiftui-lab.com/swiftui-animations-part1/
Update:
I have been working with shape path animations. Here's a GIF.
And here's the code:
IMPORTANT: The code does not animate on Xcode Live Previews. It needs to run either on the simulator or on a real device.
import SwiftUI
struct ContentView : View {
var body: some View {
RingSpinner().padding(20)
}
}
struct RingSpinner : View {
#State var pct: Double = 0.0
var animation: Animation {
Animation.basic(duration: 1.5).repeatForever(autoreverses: false)
}
var body: some View {
GeometryReader { geometry in
ZStack {
Path { path in
path.addArc(center: CGPoint(x: geometry.size.width/2, y: geometry.size.width/2),
radius: geometry.size.width/2,
startAngle: Angle(degrees: 0),
endAngle: Angle(degrees: 360),
clockwise: true)
}
.stroke(Color.green, lineWidth: 40)
InnerRing(pct: self.pct).stroke(Color.yellow, lineWidth: 20)
}
}
.aspectRatio(1, contentMode: .fit)
.padding(20)
.onAppear() {
withAnimation(self.animation) {
self.pct = 1.0
}
}
}
}
struct InnerRing : Shape {
var lagAmmount = 0.35
var pct: Double
func path(in rect: CGRect) -> Path {
let end = pct * 360
var start: Double
if pct > (1 - lagAmmount) {
start = 360 * (2 * pct - 1.0)
} else if pct > lagAmmount {
start = 360 * (pct - lagAmmount)
} else {
start = 0
}
var p = Path()
p.addArc(center: CGPoint(x: rect.size.width/2, y: rect.size.width/2),
radius: rect.size.width/2,
startAngle: Angle(degrees: start),
endAngle: Angle(degrees: end),
clockwise: false)
return p
}
var animatableData: Double {
get { return pct }
set { pct = newValue }
}
}