← All shorts
12iOS 17+2 min read

KeyframeAnimator: Independent Timelines Per Property

Animate scale, offset, and rotation on their own timelines with keyframeAnimator, instead of one shared curve.

A realistic shake needs two timelines running at once: offset snapping back and forth fast, rotation easing more slowly over the top of it. withAnimation gives you one. It applies a single curve to every property changed inside the block, so independent timing meant splitting the shake into several hand-staggered calls.

#The old way

withAnimation(.linear(duration: 0.05)) { offset = -8 }
withAnimation(.linear(duration: 0.05).delay(0.05)) { offset = 8 }
withAnimation(.easeInOut(duration: 0.2)) { rotation = .degrees(5) }

Every property carries its own delay math. Keep three or four of these in sync by hand and it turns fragile fast.

#The new way

import SwiftUI

struct ShakeValues: Equatable {
    var offset: CGFloat = 0
    var rotation: Angle = .zero
}

struct PasswordField: View {
    @State private var errorTrigger = 0

    var body: some View {
        VStack(spacing: 16) {
            SecureField("Password", text: .constant(""))
                .textFieldStyle(.roundedBorder)
                .keyframeAnimator(
                    initialValue: ShakeValues(),
                    trigger: errorTrigger
                ) { content, value in
                    content
                        .offset(x: value.offset)
                        .rotationEffect(value.rotation)
                } keyframes: { _ in
                    KeyframeTrack(\.offset) {
                        LinearKeyframe(0, duration: 0.05)
                        LinearKeyframe(-8, duration: 0.05)
                        LinearKeyframe(8, duration: 0.05)
                        LinearKeyframe(-8, duration: 0.05)
                        LinearKeyframe(0, duration: 0.05)
                    }
                    KeyframeTrack(\.rotation) {
                        LinearKeyframe(.zero, duration: 0.1)
                        LinearKeyframe(.degrees(5), duration: 0.15)
                        LinearKeyframe(.zero, duration: 0.2)
                    }
                }

            Button("Simulate Wrong Password") {
                errorTrigger += 1
            }
        }
    }
}

#Why it matters

  • Each KeyframeTrack is a self-contained timeline for one property. The offset track above runs 0.25s, the rotation track 0.45s, and neither has to be padded to match the other.
  • LinearKeyframe, SpringKeyframe, and CubicKeyframe mix inside a single track. A value can ease in and then spring to a stop without ever leaving keyframeAnimator.
  • Bumping trigger replays the whole timeline from scratch. That fits one-shot feedback like a validation error better than a looping animation would.

#Gotcha

The first keyframe in a track is not an implicit "from". It's a jump target.

The resting state here is offset: 0 and rotation: .zero, matching ShakeValues()'s defaults. If a track's opening keyframe doesn't match that value, the property visibly pops to the first keyframe the instant the animation starts, instead of animating smoothly from where it already was. Set your Value struct's defaults to match the at-rest UI state before you write the rest of the track.

swiftuiios17animation

Related shorts