32

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()
    }
}
Asperi
  • 228,894
  • 20
  • 464
  • 690
Arman
  • 1,208
  • 1
  • 12
  • 17

2 Answers2

45

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

}
Martijn Pieters
  • 1,048,767
  • 296
  • 4,058
  • 3,343
Asperi
  • 228,894
  • 20
  • 464
  • 690
  • Thank you. You changed it to implicit animation. I want to keep explicit animation. – Arman Oct 28 '20 at 05:10
  • It is explicit, because linked to value. – Asperi Oct 28 '20 at 05:12
  • 3
    My bad, i missed that final "value" param in .animation() method. Both of your solutions work, thank you! BTW, do you have any idea why in my original code explicit animation was initiating NavigationView animation? Conceptually it shouldn't. Is this a bug? – Arman Oct 28 '20 at 08:24
  • 12
    Do you know why wrapping `withAnimation` in `DispatchQueue.main.async {}` resolves the issue? – Tyler Pashigian Jan 25 '21 at 21:34
  • @TylerPashigian You actually don't need to wrap `withAnimation` with`DispatchQueue.main.async {}`. My code works without it. – Ugo Mar 09 '21 at 14:22
  • 6
    I had the same issue and wrapping in DispatchQueue.main.async helped me. But I really want to know why and how it works :) – badadin Apr 06 '21 at 07:46
  • Worked for me too. But I'm in the same boat as @badadin, and really curious: Does anyone know why simply wrapping with `DispatchQueue.main.async {}` makes things work? – Cem Schemel Jul 18 '21 at 19:14
  • @TylerPashigian and others, it is separation of constructions in different run loop events. – Asperi Jul 18 '21 at 19:51
  • This feels like Android's `post` method (either on View or on a Handler) – Niklas Oct 20 '21 at 16:00
  • 3
    Man, SwiftUI got a long way to go. Sad. – zumzum Nov 12 '21 at 20:20
  • I feel like this is an issue specifically with NavigationView's but I'm not sure. I'm also trying to write a wrapper around withAnimation(...) function to always jump on the main thread but it's returning an implicit `Result` type and when I jump on the main inside the function, that's not working – C0D3 Dec 08 '21 at 15:45
0

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.

C0D3
  • 6,440
  • 8
  • 43
  • 67