'SwiftUI: Broken explicit animations in NavigationView?

When I put an explicit animation inside a NavigationView, as an undesirable side effect, it animates the initial layout of the NavigationView content. It gets especially bad with infinite animations. Is there a way to disable this side effect?

Example: the image below is supposed to be an animated red loader on a full screen blue background. Instead I get this infinite loop of a scaling blue background:

enter image description here

import SwiftUI

struct EscapingAnimationTest: View {
    var body: some View {
        NavigationView {
            VStack {
                Spacer()
                EscapingAnimationTest_Inner()
                Spacer()
            }
            .backgroundFill(Color.blue)
        }
    }
}

struct EscapingAnimationTest_Inner: View {
    @State var degrees: CGFloat = 0
    
    var body: some View {
        Circle()
            .trim(from: 0.0, to: 0.3)
            .stroke(Color.red, lineWidth: 5)
            .rotationEffect(Angle(degrees: degrees))
            .onAppear() {
                withAnimation(Animation.linear(duration: 1).repeatForever(autoreverses: false)) {
                    degrees = 360
                }
            }
    }
}

struct EscapingAnimationTest_Previews: PreviewProvider {
    static var previews: some View {
        EscapingAnimationTest()
    }
}


Solution 1:[1]

Here is fixed part (another my answer with explanations is here).

Tested with Xcode 12 / iOS 14.

demo

struct EscapingAnimationTest_Inner: View {
    @State var degrees: CGFloat = 0
    
    var body: some View {
        Circle()
            .trim(from: 0.0, to: 0.3)
            .stroke(Color.red, lineWidth: 5)
            .rotationEffect(Angle(degrees: Double(degrees)))
            .animation(Animation.linear(duration: 1).repeatForever(autoreverses: false), value: degrees)
            .onAppear() {
                DispatchQueue.main.async {   // << here !!
                    degrees = 360
                }
            }
    }
}

Update: the same will be using withAnimation

.onAppear() {
    DispatchQueue.main.async {
        withAnimation(Animation.linear(duration: 1).repeatForever(autoreverses: false)) {
           degrees = 360
        }
    }

}

backup

Solution 2:[2]

Using DispatchQueue.main.async before outside of the withAnimation blocks worked for me but this code didn't look very clean. I found another (and in my opinion cleaner) solution which is this:

  • Create a isAnimating variable outside of the body
@State var isAnimating = false 

Then at the end of your outer VStack, set this variable to true inside onAppear. Then call rotationEffect with isAnimating ternary operator and then clal .animation() after. Here is the full code:

    var body: some View {
        VStack {
            // the trick is to use .animation and some helper variables
            Circle()
               .trim(from: 0.0, to: 0.3)
               .stroke(Color.red, lineWidth: 5)
               .rotationEffect(Angle(degrees: isAnimating ? 360 : 0))
               .animation(Animation.linear(duration:1).repeatForever(autoreverses: false), value: isAnimating)
        } //: VStack
        .onAppear {
            isAnimating = true
        }
    }

This way you don't need to use DispatchQueue.main.async.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2