Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

SwiftUI unexpected animations when toggling animations In .onAppear (using a GeometryReader's size)

I have a strange animation behavior in SwiftUI. I've tried to create a minimal view that demonstrates it below.

I want to animate in three circles with a fade and a scale effect (see column "What I Expect" below). However, the size of the circles depends on the width of the view, so I'm using a GeometryReader to get that.

I want to start the animation in .onAppear(perform:), but at the time that is called, the GeometryReader hasn't set the size property yet. What I end up with is the animation you see in "Unwanted Animation 1". This is due to the frames being animated from .zero to their correct sizes.

However, whenever I try to disable the animations for the frames by adding a .animation(nil, value: size) modifier, I get an extremely strange animation behavior (see "Unwanted Animation 2"). This I don't understand at all. It somehow adds a horizontal translation to the animation which makes it look even worse. Any ideas what's happening here and how to solve this?

Strangely, everything works fine if I use an explicit animation like this:

.onAppear {
    withAnimation {
      show.toggle()
    }
}

But I want to understand what's going on here.

Thanks!

Update:

Would replacing .onAppear(perform:) with the following code be reasonable? This would trigger only once in the lifetime of the view, right when size changes from .zero to the correct value.

.onChange(of: size) { [size] newValue in
    guard size == .zero else { return }
    show.toggle()
}

What I Expect Unwanted Animation 1 Unwanted Animation 2
enter image description here enter image description here enter image description here
import SwiftUI

struct TestView: View {
    @State private var show = false
    @State private var size: CGSize = .zero

    var body: some View {
        VStack {
            circle
            circle
            circle
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .contentShape(Rectangle())
        .background {
            GeometryReader { proxy in
                Color.clear.onAppear { size = proxy.size }
            }
        }
        .onAppear { show.toggle() }
    }

    private var circle: some View {
        Circle()
            .frame(width: circleSize, height: circleSize)
            .animation(nil, value: size) // This make the circles animate in from the side for some reason (see "Strange Animation 2")
            .opacity(show ? 1 : 0)
            .scaleEffect(show ? 1 : 2)
            .animation(.easeInOut(duration: 1), value: show)
    }

    private var circleSize: Double {
        size.width * 0.2 // Everything works fine if this is a constant
    }
}


struct TestView_Previews: PreviewProvider {
    static var previews: some View {
        TestView()
    }
}
like image 709
Quantm Avatar asked Sep 07 '25 13:09

Quantm


1 Answers

The real size is known after first layout, but .onAppear is called before, so layout (including frame change, which is animatable) goes under animation.

To solve this we need to delay state change a bit (until first layout/render finished), like

.onAppear {
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
        show.toggle()
    }
}

... and this is why withAnimation also works - it actually delays closure call to next cycle.

Tested with Xcode 13 / iOS 15

like image 138
Asperi Avatar answered Sep 10 '25 13:09

Asperi