Today I faced an issue with animations depending on which kind of ForEach I use. To be honest I don’t understand what exactly the issue is.
I created this little example for you.
The AudioIndicators View contains 3 different examples of animated bars. Only the last one (green) is animating fine. The only difference is the kind of ForEach I used.
Can someone explain why the animation is not working for the blue and red bars?
import SwiftUI
struct BarView: View {
var value: CGFloat
var tint: Color = .accentColor
var barHeight: CGFloat = 100
var body: some View {
RoundedRectangle(cornerRadius: 3)
.fill(tint)
.frame(width: .infinity, height: barHeight * value)
.animation(.linear(duration: 0.5))
}
}
struct AudioIndicators: View {
@Binding var samples: [CGFloat]
let barSpacing: CGFloat = 4
var body: some View {
VStack(alignment: .center) {
HStack(spacing: 32) {
// BLUE
HStack(alignment: .center, spacing: barSpacing) {
ForEach(samples, id: \.self) { sample in
BarView(value: sample, tint: .blue)
}
}
// RED
HStack(alignment: .center, spacing: barSpacing) {
ForEach(Array(samples.enumerated()), id: \.element) { index, sample in
BarView(value: sample, tint: Color.red)
}
}
// GREEN
HStack(alignment: .center, spacing: barSpacing) {
ForEach(0 ..< samples.count, id: \.self) { i in
BarView(value: samples[i], tint: Color.green)
}
}
}
}
}
}
struct ContentView: View {
let timer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()
@State var samples: [CGFloat] = [0, 0, 0, 0, 0]
var body: some View {
AudioIndicators(samples: $samples)
.onReceive(timer) { input in
samples = randomSamples()
}
}
func randomSamples() -> [CGFloat] {
[randomSample(), randomSample(), randomSample(), randomSample(), randomSample()]
}
func randomSample() -> CGFloat {
CGFloat.random(in: 0 ... 2)
}
}
#Preview {
ContentView()
}
The identities of views play an important part in how they are animated.
If the identity of a view has changed, that means (as far as SwiftUI is concerned) this is a different view. If the view update is animated, SwiftUI will treat these as separate views - the old view disappears, and a new view appears.
If the view's identity doesn't change, it's still the same view. If there are other changes to the view, and the view update is animated, SwiftUI will animate the other changes to the view.
In your code, the BarView
s created by the first two ForEach
es all change identities when samples
is updated. Namely, their identities are the CGFloat
s in the sample. For example, if samples
is [0.1, 0.2, 0.3]
, then the first BarView
has id 0.1
, the second has 0.2
, and the third BarView
has id 0.3
.
Suppose samples
changes to [0.4, 0.5, 0.6]
. All the existing BarView
s change identity, so they disappear, and new BarView
s with new identities are created. This is not animated, but you can animate it by adding:
.animation(.linear(duration: 0.5), value: samples)
in AudioIndicators
. You will see that the bars fade in and fade out.
In the third ForEach
, the views' identities don't change when samples
changes. The first bar always has identity 0
, the second bar always has identity 1
, etc. Therefore, when samples
change, SwiftUI can understand that this is the same bar, just with a different height. And because you added the animation
modifier to the rectangle, it animates the change in the bar's height.
For more info about identities, see Demystifying SwiftUI.
Side notes:
width: .infinity
. Dimensions like this should not be negative or infinite. You might have meant maxWidth: .infinity
(which would need to go in a different frame
modifier), but shapes like RoundedRectangle
, naturally expand to fill the available space, so there is no need to do that.animation
modifier is deprecated. You should add a value:
argument to it, to indicate which property you want to animate. e.g. .animation(.linear(duration: 0.5), value: value)