The single most common SwiftUI animation question is a version of this: the animation is written correctly, repeatForever is there, and nothing moves.

The reason is that SwiftUI animates changes. An animation modifier does not start anything — it describes how a change should be drawn if one happens. If the state never changes, there is no change to draw, and the view sits there.

onAppear is what supplies that first change.

Equalizer.swiftplays as written
import SwiftUI

struct Equalizer: View {
    @State private var tall = false
    var bars: Int = 4

    var body: some View {
        HStack(alignment: .bottom, spacing: 5) {
            ForEach(0..<bars, id: \.self) { i in
                Capsule()
                    .fill(.purple)
                    .frame(width: 7, height: tall ? 42 : 14)
                    .animation(
                        .easeInOut(duration: 0.9)
                            .repeatForever()
                            .delay(Double(i) * 0.14),
                        value: tall
                    )
            }
        }
        .frame(height: 44)
        .onAppear { tall = true }
    }
}

tall flips exactly once, on appear. Every bar's height depends on it, so every bar animates — and repeatForever keeps each one going.

Staggering

The stagger is one line: .delay(Double(i) * step). Each item runs the identical animation, just started later. Multiply the index by a fraction of the period and the row becomes a wave rather than four bars moving as one.

StepReads as
0One block moving
period / countA travelling wave
period / 2Alternating, like a metronome

The bug where it jumps instead of animating

If the state flip happens before the view has a frame, there is nothing to interpolate from and the change lands instantly. It shows up most often when onAppear sets several things at once, or when the view is inside a container that lays out late.

The fix is to let the layout settle first:

Let it land firstXcode; Swoop plays the plain form
.onAppear {
    // One runloop turn is enough — the view has a frame by the time this runs.
    DispatchQueue.main.async { tall = true }
}

Note. Swoop's player runs a subset of SwiftUI and does not include DispatchQueue. The plain .onAppear { tall = true } form above plays on the phone; this variant is for Xcode, when you hit the timing problem in a real screen.

onAppear is not viewDidAppear

Two habits from UIKit that cause trouble:

  • It can fire more than once. A view that is removed and re-added gets another onAppear. If you are starting something expensive, guard it.
  • It fires before the view is on screen, not after. That is precisely why the animation sometimes has no frame to animate from.

The alternative: don't use state at all

If the motion is continuous, onAppear plus repeatForever is a workaround for not having a clock. TimelineView(.animation) gives you the clock, and then there is no state to flip and no first change to arrange — see the animation examples.

Run it

Paste Equalizer into Swoop and delete the .onAppear line. Watching it stop dead is the fastest way to internalise what animation modifiers do and don't do.