iosswiftswiftuiios-animations

Conditionally updating a second property during animation in SwiftUI


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.

anglegif


Solution

  • 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)")
            }
        }
    }