Agent skills for iOS, iPadOS, Swift, SwiftUI, and modern Apple framework development.
71
89%
Does it follow best practices?
Impact
—
No eval scenarios have been run
Advisory
Suggest reviewing before use
Detailed API reference for SwiftUI animation types, protocols, and patterns. Covers material beyond the SKILL.md summary.
Create entirely custom animation curves by conforming to CustomAnimation.
@preconcurrency protocol CustomAnimation: Hashable, Sendablefunc animate<V: VectorArithmetic>(
value: V,
time: TimeInterval,
context: inout AnimationContext<V>
) -> V?Return the interpolated value at the given time. Return nil when the
animation is complete.
func velocity<V: VectorArithmetic>(
value: V,
time: TimeInterval,
context: AnimationContext<V>
) -> V?
func shouldMerge<V: VectorArithmetic>(
previous: Animation,
value: V,
time: TimeInterval,
context: inout AnimationContext<V>
) -> Boolstruct ElasticAnimation: CustomAnimation {
let duration: TimeInterval
func animate<V: VectorArithmetic>(
value: V,
time: TimeInterval,
context: inout AnimationContext<V>
) -> V? {
guard time <= duration else { return nil }
let p = time / duration
let s = sin((20 * p - 11.125) * ((2 * .pi) / 4.5))
let progress: Double
if p < 0.5 {
progress = -(pow(2, 20 * p - 10) * s) / 2
} else {
progress = (pow(2, -20 * p + 10) * s) / 2 + 1
}
return value.scaled(by: progress)
}
}Expose custom animations as static members on Animation.
extension Animation {
static var elastic: Animation {
elastic(duration: 0.35)
}
static func elastic(duration: TimeInterval) -> Animation {
Animation(ElasticAnimation(duration: duration))
}
}
// Usage
withAnimation(.elastic(duration: 0.5)) { isActive.toggle() }| Type | Role |
|---|---|
AnimationContext<V> | Carries environment and per-animation state |
AnimationState | Key-value storage for persisted state |
AnimationStateKey | Protocol for defining custom state keys |
Spring(duration: 0.5, bounce: 0.0)duration -- Perceptual duration controlling pace. Default 0.5.bounce -- Bounciness. 0.0 = no bounce, 1.0 = undamped. Negative values
produce overdamped springs. Default 0.0.Spring(mass: 1.0, stiffness: 100.0, damping: 10.0, allowOverDamping: false)mass -- Mass at end of spring. Default 1.0.stiffness -- Spring stiffness coefficient.damping -- Friction-like drag force.allowOverDamping -- Permit damping ratio > 1. Default false.Spring(response: 0.5, dampingRatio: 0.7)response -- Stiffness expressed as approximate duration in seconds.dampingRatio -- Fraction of critical damping. 1.0 = critically damped.Spring(settlingDuration: 1.0, dampingRatio: 0.8, epsilon: 0.001)settlingDuration -- Estimated time to come to rest.dampingRatio -- Fraction of critical damping.epsilon -- Threshold for considering the spring at rest. Default 0.001.Spring.smooth // no bounce
Spring.smooth(duration: 0.5, extraBounce: 0.0)
Spring.snappy // small bounce
Spring.snappy(duration: 0.4, extraBounce: 0.1)
Spring.bouncy // visible bounce
Spring.bouncy(duration: 0.5, extraBounce: 0.2)let spring = Spring(duration: 0.5, bounce: 0.3)
let v = spring.value(target: 1.0, initialVelocity: 0.0, time: 0.25)
let vel = spring.velocity(target: 1.0, initialVelocity: 0.0, time: 0.25)
let settle = spring.settlingDuration(target: 1.0, initialVelocity: 0.0, epsilon: 0.001)let spring = Spring(duration: 0.5, bounce: 0.3)
// Access physical equivalents:
spring.mass // 1.0
spring.stiffness // 157.9
spring.damping // 17.6
spring.response
spring.dampingRatio
spring.settlingDurationMap input progress [0,1] to output progress [0,1]. Used with
.timingCurve(_:duration:).
UnitCurve.linear
UnitCurve.easeIn
UnitCurve.easeOut
UnitCurve.easeInOut
UnitCurve.circularEaseIn
UnitCurve.circularEaseOut
UnitCurve.circularEaseInOutUnitCurve.bezier(
startControlPoint: UnitPoint(x: 0.42, y: 0.0),
endControlPoint: UnitPoint(x: 0.58, y: 1.0)
)let curve = UnitCurve.easeInOut
curve.value(at: 0.5) // output progress at midpoint
curve.velocity(at: 0.5) // rate of change at midpoint
curve.inverse // swaps x and y components.animation(.timingCurve(UnitCurve.circularEaseIn, duration: 0.4), value: x)
// Cubic bezier control points
.animation(.timingCurve(0.68, -0.55, 0.27, 1.55, duration: 0.5), value: x)enum LoadPhase: CaseIterable {
case ready, loading, spinning, complete
var scale: Double {
switch self {
case .ready: 1.0
case .loading: 0.9
case .spinning: 1.0
case .complete: 1.1
}
}
var rotation: Angle {
switch self {
case .spinning: .degrees(360)
default: .zero
}
}
var opacity: Double {
self == .loading ? 0.7 : 1.0
}
}
struct LoadingIndicator: View {
var body: some View {
PhaseAnimator(LoadPhase.allCases) { phase in
Image(systemName: "arrow.triangle.2.circlepath")
.font(.title)
.scaleEffect(phase.scale)
.rotationEffect(phase.rotation)
.opacity(phase.opacity)
} animation: { phase in
switch phase {
case .ready: .smooth(duration: 0.2)
case .loading: .easeIn(duration: 0.15)
case .spinning: .linear(duration: 0.6)
case .complete: .spring(duration: 0.3, bounce: 0.4)
}
}
}
}Run through all phases once each time the trigger value changes.
struct FeedbackDot: View {
@State private var feedbackTrigger = 0
var body: some View {
Button { feedbackTrigger += 1 } label: {
Circle()
.frame(width: 20, height: 20)
.phaseAnimator(
[false, true, false],
trigger: feedbackTrigger
) { content, phase in
content.scaleEffect(phase ? 1.5 : 1.0)
} animation: { _ in
.spring(duration: 0.25, bounce: 0.5)
}
}
.buttonStyle(.plain)
}
}Text("Hello")
.phaseAnimator([0.0, 1.0, 0.0]) { content, phase in
content.opacity(phase)
} animation: { _ in .easeInOut(duration: 0.8) }struct BounceValues {
var yOffset: Double = 0
var scale: Double = 1.0
var opacity: Double = 1.0
var rotation: Angle = .zero
}
struct BouncingBadge: View {
@State private var trigger = false
var body: some View {
Button { trigger.toggle() } label: {
Text("NEW")
.font(.caption.bold())
.padding(.horizontal)
.background(.red, in: Capsule())
.keyframeAnimator(
initialValue: BounceValues(),
trigger: trigger
) { content, value in
content
.offset(y: value.yOffset)
.scaleEffect(value.scale)
.opacity(value.opacity)
.rotationEffect(value.rotation)
} keyframes: { _ in
KeyframeTrack(\.yOffset) {
SpringKeyframe(-20, duration: 0.2)
CubicKeyframe(5, duration: 0.15)
SpringKeyframe(0, duration: 0.3)
}
KeyframeTrack(\.scale) {
CubicKeyframe(1.3, duration: 0.2)
CubicKeyframe(0.95, duration: 0.15)
SpringKeyframe(1.0, duration: 0.3)
}
KeyframeTrack(\.rotation) {
LinearKeyframe(.degrees(-5), duration: 0.1)
LinearKeyframe(.degrees(5), duration: 0.1)
SpringKeyframe(.zero, duration: 0.2)
}
KeyframeTrack(\.opacity) {
MoveKeyframe(1.0)
}
}
}
.buttonStyle(.plain)
}
}KeyframeAnimator(
initialValue: PulseValues(),
repeating: true
) { value in
Circle()
.fill(.blue)
.frame(width: 40, height: 40)
.scaleEffect(value.scale)
.opacity(value.opacity)
} keyframes: { _ in
KeyframeTrack(\.scale) {
CubicKeyframe(1.3, duration: 0.5)
CubicKeyframe(1.0, duration: 0.5)
}
KeyframeTrack(\.opacity) {
CubicKeyframe(0.6, duration: 0.5)
CubicKeyframe(1.0, duration: 0.5)
}
}| Type | Interpolation | Use case |
|---|---|---|
LinearKeyframe(value, duration:) | Straight line between values | Steady movement |
CubicKeyframe(value, duration:) | Cubic bezier curve | Smooth easing |
SpringKeyframe(value, duration:, spring:) | Spring physics | Natural settle |
MoveKeyframe(value) | Instant jump | Reset to value immediately |
let timeline = KeyframeTimeline(initialValue: AnimValues()) {
KeyframeTrack(\.scale) {
CubicKeyframe(1.5, duration: 0.3)
CubicKeyframe(1.0, duration: 0.4)
}
}
let totalDuration = timeline.duration
let valueAtHalf = timeline.value(time: totalDuration / 2)A Transaction carries the animation context for a state change. Every
withAnimation call creates a transaction internally.
// Explicit transaction
var transaction = Transaction(animation: .spring)
withTransaction(transaction) {
isExpanded = true
}// Remove the incoming transaction animation for this scoped content
SomeView()
.transaction { transaction in
transaction.animation = nil
}
// Override the scoped transaction animation when a value changes
SomeView()
.transaction(value: selectedTab) { transaction in
transaction.animation = .smooth(duration: 0.3)
}Store custom metadata in transactions.
struct IsInteractiveKey: TransactionKey {
static let defaultValue = false
}
extension Transaction {
var isInteractive: Bool {
get { self[IsInteractiveKey.self] }
set { self[IsInteractiveKey.self] = newValue }
}
}
// Usage
var transaction = Transaction(animation: .interactiveSpring)
transaction.isInteractive = true
withTransaction(transaction) { dragOffset = newOffset }// Apply transaction only within a body closure
ParentView()
.transaction { $0.animation = .spring } body: { content in
content.scaleEffect(scale)
}Use .animation(_:body:) when only selected modifiers should animate.
Use .animation(_:value:) when a single value change should drive the view's
animatable modifiers together. Use .transaction(_:body:) when you need to
scope transaction overrides rather than attach one animation.
CardView(isExpanded: isExpanded)
.animation(.smooth) { content in
content
.scaleEffect(isExpanded ? 1.05 : 1.0)
.shadow(radius: isExpanded ? 12 : 4)
}| Transition | Description | Example |
|---|---|---|
.opacity | Fade in/out | .transition(.opacity) |
.slide | Slide from leading, exit trailing | .transition(.slide) |
.scale | Scale from zero | .transition(.scale) |
.scale(_:anchor:) | Scale with amount and anchor | .transition(.scale(0.5, anchor: .bottom)) |
.move(edge:) | Move from specified edge | .transition(.move(edge: .top)) |
.push(from:) | Push from edge with fade | .transition(.push(from: .trailing)) |
.offset(_:) | Offset by CGSize | .transition(.offset(CGSize(width: 0, height: 50))) |
.offset(x:y:) | Offset by x and y | .transition(.offset(x: 0, y: -100)) |
.identity | No visual change | .transition(.identity) |
.blurReplace | Blur and scale combined | .transition(.blurReplace) |
.blurReplace(_:) | Configurable blur replace | .transition(.blurReplace(.downUp)) |
.symbolEffect | Default symbol effect | .transition(.symbolEffect) |
.symbolEffect(_:options:) | Custom symbol effect | .transition(.symbolEffect(.appear)) |
// Slide + fade
.transition(.slide.combined(with: .opacity))
// Move from top + scale
.transition(.move(edge: .top).combined(with: .scale))Different animation for insertion vs removal.
.transition(.asymmetric(
insertion: .push(from: .bottom).combined(with: .opacity),
removal: .scale.combined(with: .opacity)
))struct RotateTransition: Transition {
func body(content: Content, phase: TransitionPhase) -> some View {
content
.rotationEffect(phase.isIdentity ? .zero : .degrees(90))
.opacity(phase.isIdentity ? 1 : 0)
}
}
extension AnyTransition {
static var rotate: AnyTransition {
.init(RotateTransition())
}
}enum TransitionPhase {
case willAppear // View is about to be inserted
case identity // View is fully presented
case didDisappear // View is being removed
}
// Check current phase
phase.isIdentity // true when fully presented.transition(
.move(edge: .bottom)
.combined(with: .opacity)
.animation(.spring(duration: 0.4, bounce: 0.2))
)value:)| Effect | Scope | Direction |
|---|---|---|
.bounce | .byLayer, .wholeSymbol | -- |
.wiggle | .byLayer, .wholeSymbol | .up, .down, .left, .right, .forward, .backward, .clockwise, .counterClockwise, .custom(angle:) |
Image(systemName: "bell.fill")
.symbolEffect(.bounce.byLayer, value: count)
Image(systemName: "arrow.left.arrow.right")
.symbolEffect(.wiggle.left, value: swapCount)isActive:)| Effect | Scope | Direction |
|---|---|---|
.pulse | .byLayer, .wholeSymbol | -- |
.variableColor | .byLayer, .wholeSymbol | Chaining: .cumulative/.iterative, .reversing/.nonReversing, .dimInactiveLayers/.hideInactiveLayers |
.scale | .byLayer, .wholeSymbol | .up, .down |
.breathe | .byLayer, .wholeSymbol | -- |
.rotate | .byLayer, .wholeSymbol | .clockwise, .counterClockwise |
Image(systemName: "wifi")
.symbolEffect(.pulse.byLayer, isActive: isConnecting)
Image(systemName: "gear")
.symbolEffect(.rotate.clockwise, isActive: isProcessing)
Image(systemName: "speaker.wave.3.fill")
.symbolEffect(
.variableColor.cumulative.nonReversing.dimInactiveLayers,
options: .repeating,
isActive: isPlaying
)
Image(systemName: "magnifyingglass")
.symbolEffect(.scale.up, isActive: isHighlighted)
Image(systemName: "heart.fill")
.symbolEffect(.breathe, isActive: isFavorite)Image(systemName: "checkmark.circle.fill")
.symbolEffect(.appear, isActive: showCheck)
Image(systemName: "xmark.circle")
.symbolEffect(.disappear, isActive: shouldHide)Image(systemName: isMuted ? "speaker.slash" : "speaker.wave.3")
.contentTransition(.symbolEffect(.replace.downUp))
// Magic replace (morphs between symbols)
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
.contentTransition(.symbolEffect(.replace.magic(fallback: .downUp)))Replace directions: .downUp, .offUp, .upUp.
.symbolEffect(.pulse, options: .default, isActive: true)
.symbolEffect(.bounce, options: .repeating, value: count)
.symbolEffect(.pulse, options: .nonRepeating, isActive: true)
.symbolEffect(.bounce, options: .repeat(3), value: count)
.symbolEffect(.pulse, options: .speed(2.0), isActive: true)
// RepeatBehavior
.symbolEffect(.bounce, options: .repeat(.periodic(3, delay: 0.5)), value: count)
.symbolEffect(.pulse, options: .repeat(.continuous), isActive: true)Image(systemName: "star.fill")
.symbolEffect(.pulse, isActive: true)
.symbolEffectsRemoved(reduceMotion)@Environment(\.accessibilityReduceMotion) private var reduceMotionwithAnimation(reduceMotion ? .none : .bouncy) {
isExpanded.toggle()
}Replace bouncy/spring with crossfade when reduce motion is on.
withAnimation(reduceMotion ? .easeInOut(duration: 0.2) : .spring(duration: 0.4, bounce: 0.3)) {
selectedTab = newTab
}// WRONG: Ignores reduce motion
PhaseAnimator(phases) { phase in /* ... */ }
// CORRECT: Use trigger-based or skip entirely
if !reduceMotion {
PhaseAnimator(phases) { phase in /* ... */ }
} else {
StaticView()
}Image(systemName: "wifi")
.symbolEffect(.pulse, isActive: isSearching)
.symbolEffectsRemoved(reduceMotion)extension Animation {
static func adaptive(
_ animation: Animation,
reduceMotion: Bool
) -> Animation? {
reduceMotion ? nil : animation
}
}
// Usage
withAnimation(.adaptive(.bouncy, reduceMotion: reduceMotion)) {
isVisible = true
}The content closure in KeyframeAnimator and PhaseAnimator runs every
frame while animating. Keep it to simple view modifiers.
// WRONG: Expensive computation per frame
.keyframeAnimator(initialValue: v, trigger: t) { content, value in
let result = heavyComputation(value.progress)
return content.opacity(result)
} keyframes: { _ in /* ... */ }
// CORRECT: Only apply view modifiers
.keyframeAnimator(initialValue: v, trigger: t) { content, value in
content.opacity(value.opacity)
} keyframes: { _ in /* ... */ }Animating view modifiers (opacity, scaleEffect, offset, rotationEffect)
is highly optimized. Avoid animating layout-triggering properties when possible.
ComplexAnimatedView()
.drawingGroup()Flattens the view hierarchy into a single Metal-backed layer. Use when compositing many overlapping animated views.
Avoid animating dozens of views simultaneously. Use staggered delays.
ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
ItemView(item: item)
.transition(.move(edge: .bottom).combined(with: .opacity))
.animation(.spring.delay(Double(index) * 0.05), value: isVisible)
}Ensure animated views maintain stable identity. Use explicit id() modifiers
or stable ForEach identifiers.
// WRONG: View identity changes, breaks animation
ForEach(Array(items.enumerated()), id: \.offset) { index, item in
ItemView(item: item)
}
// CORRECT: Stable identity from model
ForEach(items) { item in
ItemView(item: item)
}Isolate child geometry from parent animations when they conflict.
ParentView()
.scaleEffect(parentScale)
.geometryGroup() // children see stable geometryOverride animation for specific subtrees without affecting siblings.
// Disable animation on one child while parent animates
ChildView()
.transaction { $0.animation = nil }Use the Core Animation instrument in Xcode Instruments to verify:
skills
accessorysetupkit
references
activitykit
references
adattributionkit
references
alarmkit
references
app-clips
app-intents
references
app-store-optimization
app-store-review
apple-on-device-ai
appmigrationkit
references
audioaccessorykit
references
authentication
references
avkit
references
background-processing
references
browserenginekit
references
callkit
references
carplay
references
cloudkit
references
contacts-framework
references
core-bluetooth
references
core-data
core-motion
references
core-nfc
references
coreml
references
cryptokit
references
cryptotokenkit
references
debugging-instruments
device-integrity
references
dockkit
references
energykit
references
eventkit
references
financekit
references
focus-engine
gamekit
references
healthkit
references
homekit
references
ios-accessibility
ios-localization
ios-networking
ios-simulator
references
mapkit
metrickit
references
musickit
references
natural-language
references
paperkit
references
passkit
references
pdfkit
references
pencilkit
references
permissionkit
references
photokit
push-notifications
realitykit
references
relevancekit
references
scenekit
references
sensorkit
references
speech-recognition
spritekit
references
storekit
swift-api-design-guidelines
swift-architecture
swift-charts
references
swift-codable
swift-concurrency
swift-formatstyle
swift-language
swift-security
references
swift-testing
swiftdata
swiftlint
swiftui-animation
swiftui-gestures
references
swiftui-layout-components
swiftui-liquid-glass
references
swiftui-patterns
swiftui-performance
swiftui-uikit-interop
swiftui-webkit
tabletopkit
references
tipkit
references
vision-framework
weatherkit
references
widgetkit
references