Loading...
Loading...
Implement, review, or improve SwiftUI animations and transitions. Use when adding implicit or explicit animations with withAnimation, configuring spring animations (.smooth, .snappy, .bouncy), building phase or keyframe animations with PhaseAnimator/KeyframeAnimator, creating hero transitions with matchedGeometryEffect or matchedTransitionSource, adding SF Symbol effects (bounce, pulse, variableColor, breathe, rotate, wiggle), implementing custom Transition or CustomAnimation types, or ensuring animations respect accessibilityReduceMotion.
npx skill4agent add dpearson2699/swift-ios-skills swiftui-animation| Category | API | When to use |
|---|---|---|
| State-driven | | Simple property changes |
| Multi-phase | | Sequenced multi-step animations |
| Keyframe | | Complex multi-property choreography |
| Shared element | | Layout-driven hero transitions |
| Navigation | | NavigationStack push/pop zoom |
| View lifecycle | | Insertion and removal |
| Text content | | In-place text/number changes |
| Symbol | | SF Symbol animations |
| Custom | | Novel timing curves |
// Timing curves
.linear // constant speed
.easeIn(duration: 0.3) // slow start
.easeOut(duration: 0.3) // slow end
.easeInOut(duration: 0.3) // slow start and end
// Spring presets (preferred for natural motion)
.smooth // no bounce, fluid
.smooth(duration: 0.5, extraBounce: 0.0)
.snappy // small bounce, responsive
.snappy(duration: 0.4, extraBounce: 0.1)
.bouncy // visible bounce, playful
.bouncy(duration: 0.5, extraBounce: 0.2)
// Custom spring
.spring(duration: 0.5, bounce: 0.3, blendDuration: 0.0)
.spring(Spring(duration: 0.6, bounce: 0.2), blendDuration: 0.0)
.interactiveSpring(response: 0.15, dampingFraction: 0.86)withAnimation(.spring) { isExpanded.toggle() }
// With completion (iOS 17+)
withAnimation(.smooth(duration: 0.35), completionCriteria: .logicallyComplete) {
isExpanded = true
} completion: { loadContent() }Circle()
.scaleEffect(isActive ? 1.2 : 1.0)
.opacity(isActive ? 1.0 : 0.6)
.animation(.bouncy, value: isActive)// Perceptual (preferred)
Spring(duration: 0.5, bounce: 0.3)
// Physical
Spring(mass: 1.0, stiffness: 100.0, damping: 10.0)
// Response-based
Spring(response: 0.5, dampingRatio: 0.7)
// Settling-based
Spring(settlingDuration: 1.0, dampingRatio: 0.8).smooth.snappy.bouncyenum PulsePhase: CaseIterable {
case idle, grow, shrink
}
struct PulsingDot: View {
var body: some View {
PhaseAnimator(PulsePhase.allCases) { phase in
Circle()
.frame(width: 40, height: 40)
.scaleEffect(phase == .grow ? 1.4 : 1.0)
.opacity(phase == .shrink ? 0.5 : 1.0)
} animation: { phase in
switch phase {
case .idle: .easeIn(duration: 0.2)
case .grow: .spring(duration: 0.4, bounce: 0.3)
case .shrink: .easeOut(duration: 0.3)
}
}
}
}PhaseAnimator(PulsePhase.allCases, trigger: tapCount) { phase in
// ...
} animation: { _ in .spring(duration: 0.4) }struct AnimValues {
var scale: Double = 1.0
var yOffset: Double = 0.0
var opacity: Double = 1.0
}
struct BounceView: View {
@State private var trigger = false
var body: some View {
Image(systemName: "star.fill")
.font(.largeTitle)
.keyframeAnimator(
initialValue: AnimValues(),
trigger: trigger
) { content, value in
content
.scaleEffect(value.scale)
.offset(y: value.yOffset)
.opacity(value.opacity)
} keyframes: { _ in
KeyframeTrack(\.scale) {
SpringKeyframe(1.5, duration: 0.3)
CubicKeyframe(1.0, duration: 0.4)
}
KeyframeTrack(\.yOffset) {
CubicKeyframe(-30, duration: 0.2)
CubicKeyframe(0, duration: 0.4)
}
KeyframeTrack(\.opacity) {
LinearKeyframe(0.6, duration: 0.15)
LinearKeyframe(1.0, duration: 0.25)
}
}
.onTapGesture { trigger.toggle() }
}
}LinearKeyframeCubicKeyframeSpringKeyframeMoveKeyframerepeating: trueAnimatableData// WRONG: Manual AnimatableData (verbose, error-prone)
struct WaveShape: Shape, Animatable {
var frequency: Double
var amplitude: Double
var phase: Double
var animatableData: AnimatablePair<Double, AnimatablePair<Double, Double>> {
get { AnimatablePair(frequency, AnimatablePair(amplitude, phase)) }
set {
frequency = newValue.first
amplitude = newValue.second.first
phase = newValue.second.second
}
}
// ...
}
// CORRECT: @Animatable macro synthesizes animatableData
@Animatable
struct WaveShape: Shape {
var frequency: Double
var amplitude: Double
var phase: Double
@AnimatableIgnored var lineWidth: CGFloat
func path(in rect: CGRect) -> Path {
// draw wave using frequency, amplitude, phase
}
}VectorArithmetic@AnimatableIgnoredstruct HeroView: View {
@Namespace private var heroSpace
@State private var isExpanded = false
var body: some View {
if isExpanded {
DetailCard()
.matchedGeometryEffect(id: "card", in: heroSpace)
.onTapGesture {
withAnimation(.spring(duration: 0.4, bounce: 0.2)) {
isExpanded = false
}
}
} else {
ThumbnailCard()
.matchedGeometryEffect(id: "card", in: heroSpace)
.onTapGesture {
withAnimation(.spring(duration: 0.4, bounce: 0.2)) {
isExpanded = true
}
}
}
}
}matchedTransitionSource.navigationTransition(.zoom(...))struct GalleryView: View {
@Namespace private var zoomSpace
let items: [GalleryItem]
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))]) {
ForEach(items) { item in
NavigationLink {
GalleryDetail(item: item)
.navigationTransition(
.zoom(sourceID: item.id, in: zoomSpace)
)
} label: {
ItemThumbnail(item: item)
.matchedTransitionSource(
id: item.id, in: zoomSpace
)
}
}
}
}
}
}
}.navigationTransitionif showBanner {
BannerView()
.transition(.move(edge: .top).combined(with: .opacity))
}.opacity.slide.scale.scale(_:anchor:).move(edge:).push(from:).offset(x:y:).identity.blurReplace.blurReplace(_:).symbolEffect.symbolEffect(_:options:).transition(.asymmetric(
insertion: .push(from: .bottom),
removal: .opacity
))Text("\(score)")
.contentTransition(.numericText(countsDown: false))
.animation(.snappy, value: score)
// For SF Symbols
Image(systemName: isMuted ? "speaker.slash" : "speaker.wave.3")
.contentTransition(.symbolEffect(.replace.downUp)).identity.interpolate.opacity.numericText(countsDown:).numericText(value:).symbolEffect// Discrete (triggers on value change)
Image(systemName: "bell.fill")
.symbolEffect(.bounce, value: notificationCount)
Image(systemName: "arrow.clockwise")
.symbolEffect(.wiggle.clockwise, value: refreshCount)
// Indefinite (active while condition holds)
Image(systemName: "wifi")
.symbolEffect(.pulse, isActive: isSearching)
Image(systemName: "mic.fill")
.symbolEffect(.breathe, isActive: isRecording)
// Variable color with chaining
Image(systemName: "speaker.wave.3.fill")
.symbolEffect(
.variableColor.iterative.reversing.dimInactiveLayers,
options: .repeating,
isActive: isPlaying
).bounce.pulse.variableColor.scale.appear.disappear.replace.breathe.rotate.wiggle.byLayer.wholeSymbol// WRONG: .animation without value triggers on any state change
Text("Hello")
.opacity(isVisible ? 1 : 0)
.animation(.easeIn)
// CORRECT: Bind to the specific value
Text("Hello")
.opacity(isVisible ? 1 : 0)
.animation(.easeIn, value: isVisible)// WRONG: Heavy computation every frame
.keyframeAnimator(initialValue: vals, trigger: t) { content, value in
let filtered = applyExpensiveFilter(content) // runs every frame
return filtered.opacity(value.opacity)
} keyframes: { _ in /* ... */ }
// CORRECT: Precompute outside, animate only visual properties
.keyframeAnimator(initialValue: vals, trigger: t) { content, value in
content.opacity(value.opacity)
} keyframes: { _ in /* ... */ }// WRONG: Ignores accessibility setting
withAnimation(.bouncy) { showDetail = true }
// CORRECT: Respect reduce motion
@Environment(\.accessibilityReduceMotion) private var reduceMotion
withAnimation(reduceMotion ? .none : .bouncy) { showDetail = true }// WRONG: Both visible with same ID -- undefined behavior
HStack {
Circle().matchedGeometryEffect(id: "dot", in: ns)
Circle().matchedGeometryEffect(id: "dot", in: ns)
}
// CORRECT: Only one source visible at a time via conditional
if onLeft {
Circle().matchedGeometryEffect(id: "dot", in: ns)
} else {
Circle().matchedGeometryEffect(id: "dot", in: ns)
}// WRONG: UIKit patterns in SwiftUI
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
withAnimation { isVisible = true }
}
UIView.animate(withDuration: 0.3) { /* ... */ }
// CORRECT: SwiftUI animation with delay
withAnimation(.spring.delay(0.5)) { isVisible = true }
withAnimation(.easeInOut(duration: 0.3)) { /* state change */ }// WRONG: No animation -- content transition has no effect
Text("\(count)")
.contentTransition(.numericText(countsDown: true))
// CORRECT: Pair with animation modifier
Text("\(count)")
.contentTransition(.numericText(countsDown: true))
.animation(.snappy, value: count)// WRONG: Applied inside a container
NavigationLink {
VStack {
DetailView(item: item)
.navigationTransition(.zoom(sourceID: item.id, in: ns))
}
} label: { /* ... */ }
// CORRECT: Applied on the outermost destination view
NavigationLink {
DetailView(item: item)
.navigationTransition(.zoom(sourceID: item.id, in: ns))
} label: { /* ... */ }withAnimation.animation(_:value:)valuematchedGeometryEffectidnamespace@AnimatableanimatableDataaccessibilityReduceMotionDispatchQueueUIView.animate.transition()contentTransition.animation(_:value:)references/animation-advanced.md