A realistic shake — offset snapping back and forth quickly while a rotation eases more slowly — needs each property animating on its own timeline. withAnimation applies one curve to every property changed inside it, so getting independent timing meant splitting the animation into several manually staggered blocks.
#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) }
Each property fights for its own delay math, and keeping three or four of these in sync by hand gets 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 — durations for offset and rotation don't need to add up to the same total by hand. LinearKeyframe,SpringKeyframe, andCubicKeyframemix within a single track, so a value can ease in, spring, and settle without leavingkeyframeAnimator.- The
triggerparameter replays the whole timeline from scratch on demand, which fits one-shot feedback like validation errors better than a looping animation would.
#Gotcha
The first keyframe in a track is not an implicit "from" — it's a jump target. If a track's opening keyframe doesn't match the value's current resting state (here, offset: 0 and rotation: .zero, matching ShakeValues()'s defaults), the property visibly pops to that first keyframe the instant the animation starts, instead of animating smoothly from where it already was. Always set your Value struct's defaults to match the at-rest UI state before writing the rest of the track.