Search code examples
swiftuiswiftui-animation

SwiftUI Animation Issue ForEach


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()
}

Solution

  • 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 BarViews created by the first two ForEaches all change identities when samples is updated. Namely, their identities are the CGFloats 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 BarViews change identity, so they disappear, and new BarViews 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:

    • You should not write 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.
    • The one-argument 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)