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
KeyframeTrackis 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, andCubicKeyframemix inside a single track. A value can ease in and then spring to a stop without ever leavingkeyframeAnimator.- Bumping
triggerreplays 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.