I have an animated Arc
drawn in SwiftUI that represents data coming from two devices. When the arc animates to the left, the "gauge" is indicating that left device's data is higher than the right's and vice versa. The "top" is zero, which is 270 degrees when drawing an Arc
shape. Because of this, I have a conditional set on a clockwise
property so that the animation appears to go to the left or right of zero (270):
var clockwise: Bool {
get { endAngle.degrees > 270 ? false : true }
}
When the property endAngle
goes from less than 270 to greater than 270, or the reverse of this, the arc isn't drawn properly and appears as a circle because clockwise isn't set for portion of the animation passing through 270 to the new endAngle
.
Is there a way to delay the change in clockwise
until the animation passes through 270 degrees?
I've included the code for the view below with some comments. In order to animate Angle
, it has to conform to VectorArithmetic
which is the reason for the extension.
struct AverageGauge: View {
@State var endAngle = Angle(degrees: 271.0)
// Property I'd like to update during the animation
var clockwise: Bool {
get { endAngle.degrees > 270 ? false : true }
}
var body: some View {
VStack {
Arc(startAngle: .degrees(270), endAngle: endAngle,
clockwise: clockwise)
.stroke(.red, style: StrokeStyle(lineWidth: 10, lineCap: .round, lineJoin: .round))
.frame(width: 100, height: 100)
// Tap gesture simulates changing data
.onTapGesture {
withAnimation(Animation.easeIn(duration: 2.0)) {
// endAngle animated here
endAngle = Angle(degrees: Double.random(in: 180...360))
}
}
Text(String(describing: endAngle.degrees))
Text("\(String(clockwise))")
}
}
}
struct Arc: Shape {
var startAngle: Angle
var endAngle: Angle
var clockwise: Bool
// var startAngleAnimatable: Angle {
// get { startAngle }
// set {startAngle = Angle(degrees: 270.0) }
// }
// Required to animate endAngle
var animatableData: Angle {
get { endAngle }
set { endAngle = newValue }
}
// var clockwiseAnimatable: Bool {
// get { clockwise }
// set { clockwise = newValue }
// }
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
}
}
extension Angle: VectorArithmetic {
public static var zero = Angle(degrees: 0.0)
public static func + (lhs: Angle, rhs: Angle) -> Angle {
Angle(degrees: lhs.degrees + rhs.degrees)
}
public static func - (lhs: Angle, rhs: Angle) -> Angle {
Angle(degrees: lhs.degrees - rhs.degrees)
}
public static func += (lhs: inout Angle, rhs: Angle) {
lhs = Angle(degrees: lhs.degrees + rhs.degrees)
}
public static func -= (lhs: inout Angle, rhs: Angle) {
lhs = Angle(degrees: lhs.degrees - rhs.degrees)
}
public mutating func scale(by rhs: Double) {
self.degrees = self.degrees * rhs
}
public var magnitudeSquared: Double {
get { 0.0 }
}
}
// Preview in case you want to paste it.
struct AverageGauge_Previews: PreviewProvider {
static var previews: some View {
AverageGauge()
}
}
Here's a gif of the animation. You can see that when the new value is on the same side as 270, it looks normal, however, when the animation traverses zero (270) it appears as a circle because clockwise
is set incorrectly.
I simplified it with a trimmed circle, leading to the same result.
If change in value from + to - or vice versa, I wait for the first animation to finish before starting the second.
struct ContentView: View {
@State var gaugeValue: Double = 0.8 // now in values -1 (-45°) to +1 (+45°)
var body: some View {
VStack {
let fraction: CGFloat = abs(gaugeValue) * 0.25
let rotation = gaugeValue < 0 ? (gaugeValue * 90) - 90 : -90
Circle()
.trim(from: 0, to: fraction )
.rotation(Angle(degrees: rotation), anchor: .center)
.stroke(.red, style: StrokeStyle(lineWidth: 10, lineCap: .round, lineJoin: .round))
.frame(width: 100, height: 100)
// Tap gesture simulates changing data
Button {
let newGaugeValue = CGFloat.random(in: -1 ... 1)
if newGaugeValue * gaugeValue < 0 { // if change of +/-
withAnimation(Animation.easeOut(duration: 1.0)) {
gaugeValue = 0
}
// delay for reaching 0 point
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
withAnimation(Animation.easeIn(duration: 1.0)) {
gaugeValue = newGaugeValue
}
}
} else { // no change of +/-
withAnimation(Animation.easeIn(duration: 1.0)) {
gaugeValue = newGaugeValue
}
}
} label: {
Text("New Data")
}
Text("\(gaugeValue)")
}
}
}