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!
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 |
---|---|---|
![]() |
![]() |
![]() |
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()
}
}
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
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With