iosswiftanimationswiftuiswiftui-animation

Issue with .animation in SwiftUI causing the entire view to move


I am having an issue after updating to Xcode 15 where my view unexpectedly moves when trying to animate a rotating Circle() in SwiftUI. Previously, this code worked perfectly, but now the entire view either shifts from the top-left corner to the bottom-right corner continuously or, in some cases, just the Circle() moves unexpectedly.

The problem seems to occur when the animation is added. If I remove the animation code, the Circle() remains fixed and displays correctly.

Here is the code I am using to display the rotating circle:

Circle()     
.stroke(style: StrokeStyle(lineWidth: 7, lineCap: .round, dash: [10, 30]))     
.frame(width: 100, height: 100, alignment: .center)     
.foregroundStyle(Color.accentColor)     
.rotationEffect(Angle(degrees: isLoading ? 0 : 360.0))     
.onAppear {         
    isLoading = true     
}     
.animation(Animation.linear(duration: 2.0).repeatForever(autoreverses: false), value: isLoading) 

When the animation is applied, the view starts moving as described. However, without the animation, everything works fine.

Does anyone know why this might be happening and how to fix it?

Environment:

Xcode 15 macOS (SwiftUI project)

Already tried this , but same result

  .onAppear(perform: {                     
              withAnimation(Animation.linear(duration: 2.0).repeatForever(autoreverses:false)){                    
        isLoading.toggle()                     
        }                 
    })                 
    .rotationEffect(Angle(degrees: isLoading ? 0 : 360.0))

Solution

  • I was able to reproduce the issue running on Mac Sequoia 15.0.1 with Xcode 16.0. It may be another case of a launch animation getting tangled with other animations when the view appears, see also:

    Here are three workarounds, none of which I can really explain, but they seem to fix the issue:

    1. Change the order of the modifiers so that the .rotationEffect comes before .frame:
    Circle()
        .stroke(style: StrokeStyle(lineWidth: 7, lineCap: .round, dash: [10, 30]))
        .rotationEffect(Angle(degrees: isLoading ? 0 : 360.0))
        .frame(width: 100, height: 100)
        .foregroundStyle(Color.accentColor)
        .onAppear {
            isLoading = true
        }
        .animation(.linear(duration: 2.0).repeatForever(autoreverses: false), value: isLoading)
    
    1. Remove the .animation modifier and set the flag using withAnimation instead.
    Circle()
        .stroke(style: StrokeStyle(lineWidth: 7, lineCap: .round, dash: [10, 30]))
        .frame(width: 100, height: 100, alignment: .center)
        .foregroundStyle(Color.accentColor)
        .rotationEffect(Angle(degrees: isLoading ? 0 : 360.0))
        .onAppear {
            withAnimation(.linear(duration: 2.0).repeatForever(autoreverses: false)) {
                isLoading = true
            }
        }
    
    1. Set the flag using .task, instead of in .onAppear:
    Circle()
        .stroke(style: StrokeStyle(lineWidth: 7, lineCap: .round, dash: [10, 30]))
        .frame(width: 100, height: 100, alignment: .center)
        .foregroundStyle(Color.accentColor)
        .rotationEffect(Angle(degrees: isLoading ? 0 : 360.0))
        .task { @MainActor in
            isLoading = true
        }
        .animation(.linear(duration: 2.0).repeatForever(autoreverses: false), value: isLoading)