swiftui

View that start moving unintentionally on NavigationStack in SwiftUI


I've created BlinkingCircle that just blinks infinitely in SwiftUI.
When I push this view on a NavigationStack, it starts to move up and down.
I can't figure out why the circle is moving.
Please help.

Here is the minimal code that reproduces the issue, along with a recording of the issue.

import SwiftUI

struct ContentView: View {
    @State private var pushedViewIDs: [String] = []

    var body: some View {
        NavigationStack(path: $pushedViewIDs) {
            Form {
                NavigationLink(value: "blinking-circle") {
                    Label("Push blinking circle", systemImage: "arrow.forward")
                }
            }
            .navigationTitle("App")
            .navigationDestination(for: String.self) { pushedViewID in
                if pushedViewID == "blinking-circle" {
                    BlinkingCircle()
                }
            }
        }
    }
}

struct BlinkingCircle: View {
    @State private var isTranslucent = false

    var body: some View {
        Circle()
            .frame(width: 200, height: 200)
            .opacity(isTranslucent ? 0.3 : 1) // If I comment this out it still moves.
            .onAppear {
                withAnimation(.linear(duration: 1).repeatForever()) {
                    isTranslucent.toggle()
                }
            }
    }
}

#Preview {
    ContentView()
}

screen record


Solution

  • When the circle has just been pushed (before onAppear), its position is a little bit above where it should be (I suspect this is because the initial position doesn't take into account the navigation bar).

    Only after that does it get re-positioned in the right place, moving it down a bit. Therefore, withAnimation also animates this change in position.

    As workingdog support Ukraine mentioned in the comments, this is no longer the behaviour in iOS 18, so this is probably a bug.

    One way to fix this is to limit the scope of the animation to just opacity.

    struct BlinkingCircle: View {
        @State private var isTranslucent = false
    
        var body: some View {
            Circle()
                .frame(width: 200, height: 200)
                .animation(nil, value: isTranslucent) // this line disables the animation before this point
                .opacity(isTranslucent ? 0.3 : 1)
                .animation(.linear(duration: 1).repeatForever(), value: isTranslucent)
                .onAppear {
                    isTranslucent.toggle()
                }
        }
    }
    

    On iOS 17, you can also do:

    struct BlinkingCircle: View {
        @State private var isTranslucent = false
    
        var body: some View {
            Circle()
                .frame(width: 200, height: 200)
                .animation(.linear(duration: 1).repeatForever()) { content in
                    content.opacity(isTranslucent ? 0.3 : 1)
                }
                .onAppear {
                    isTranslucent.toggle()
                }
        }
    }