Loading...
Loading...
Use when implementing SwiftUI animations, understanding VectorArithmetic, using @Animatable macro, zoom transitions, UIKit/AppKit animation bridging, choosing between spring and timing curve animations, or debugging animation behavior - comprehensive animation reference from iOS 13 through iOS 26
npx skill4agent add charleswiltgen/axiom axiom-swiftui-animation-ref.opacity(0) → .opacity(1)0.0 → 0.02 → 0.05 → 0.1 → 0.25 → 0.4 → 0.6 → 0.8 → 1.0VectorArithmeticprotocol VectorArithmetic {
// Compute difference between two values
static func - (lhs: Self, rhs: Self) -> Self
// Scale values
static func * (lhs: Self, rhs: Double) -> Self
// Add values
static func + (lhs: Self, rhs: Self) -> Self
// Zero value
static var zero: Self { get }
}CGFloatDoubleFloatAngleCGPointCGSizeCGRectIntstruct CounterView: View {
@State private var count: Int = 0
var body: some View {
Text("\(count)")
.animation(.spring, value: count)
}
}struct AnimatedCounterView: View {
@State private var count: Float = 0
var body: some View {
Text("\(Int(count))")
.animation(.spring, value: count)
}
}0.0 → ... → 100.0.scaleEffect(selected ? 1.5 : 1.0)selectedtrue1.51.0 → 1.1 → 1.2 → 1.3 → 1.4 → 1.5Animatableprotocol Animatable {
associatedtype AnimatableData: VectorArithmetic
var animatableData: AnimatableData { get set }
}.scaleEffect().rotationEffect().offset().opacity().blur().shadow()CircleRectangleRoundedRectangleCapsuleEllipsePathShapeAnimatablePairstruct ScaleEffectModifier: ViewModifier, Animatable {
var scale: CGSize
var anchor: UnitPoint
// Combine two 2D vectors into one 4D vector
var animatableData: AnimatablePair<CGSize.AnimatableData, UnitPoint.AnimatableData> {
get {
AnimatablePair(scale.animatableData, anchor.animatableData)
}
set {
scale.animatableData = newValue.first
anchor.animatableData = newValue.second
}
}
func body(content: Content) -> some View {
content.scaleEffect(scale, anchor: anchor)
}
}CGSizeUnitPointAnimatablePairstruct AnimatableNumberView: View, Animatable {
var number: Double
var animatableData: Double {
get { number }
set { number = newValue }
}
var body: some View {
Text("\(Int(number))")
.font(.largeTitle)
}
}
// Usage
AnimatableNumberView(number: value)
.animation(.spring, value: value)numberbodynumberbody.scaleEffect().opacity()// This is expensive but necessary for animating along a circular path
@Animatable
struct RadialLayout: Layout {
var offsetAngle: Angle
var animatableData: Angle.AnimatableData {
get { offsetAngle.animatableData }
set { offsetAngle.animatableData = newValue }
}
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
proposal.replacingUnspecifiedDimensions()
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let radius = min(bounds.width, bounds.height) / 2
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let angleStep = Angle.degrees(360.0 / Double(subviews.count))
for (index, subview) in subviews.enumerated() {
let angle = offsetAngle + angleStep * Double(index)
let x = center.x + radius * cos(angle.radians)
let y = center.y + radius * sin(angle.radians)
subview.place(at: CGPoint(x: x, y: y), anchor: .center, proposal: .unspecified)
}
}
}offsetAngle@AnimatableAnimatableanimatableDataAnimatablePair@Animatable@MainActor
@Animatable
struct MyView: View {
var scale: CGFloat
var opacity: Double
var body: some View {
// ...
}
}AnimatableanimatableDataAnimatablePairstruct HikingRouteShape: Shape {
var startPoint: CGPoint
var endPoint: CGPoint
var elevation: Double
var drawingDirection: Bool // Don't want to animate this
// Tedious manual animatableData declaration
var animatableData: AnimatablePair<AnimatablePair<CGFloat, CGFloat>,
AnimatablePair<Double, AnimatablePair<CGFloat, CGFloat>>> {
get {
AnimatablePair(
AnimatablePair(startPoint.x, startPoint.y),
AnimatablePair(elevation, AnimatablePair(endPoint.x, endPoint.y))
)
}
set {
startPoint = CGPoint(x: newValue.first.first, y: newValue.first.second)
elevation = newValue.second.first
endPoint = CGPoint(x: newValue.second.second.first, y: newValue.second.second.second)
}
}
func path(in rect: CGRect) -> Path {
// Drawing code
}
}@Animatable
struct HikingRouteShape: Shape {
var startPoint: CGPoint
var endPoint: CGPoint
var elevation: Double
@AnimatableIgnored
var drawingDirection: Bool // Excluded from animation
func path(in rect: CGRect) -> Path {
// Drawing code
}
}@AnimatableIgnored@MainActor
@Animatable
struct ProgressView: View {
var progress: Double // Animated
var totalItems: Int // Animated (if Float, not if Int)
@AnimatableIgnored
var title: String // Not animated
@AnimatableIgnored
var startTime: Date // Not animated
@AnimatableIgnored
var debugEnabled: Bool // Not animated
var body: some View {
VStack {
Text(title)
ProgressBar(value: progress)
if debugEnabled {
Text("Started: \(startTime.formatted())")
}
}
}
}@MainActor
@Animatable
struct StockPriceView: View {
var price: Double
var changePercent: Double
var body: some View {
VStack(alignment: .trailing) {
Text("$\(price, format: .number.precision(.fractionLength(2)))")
.font(.title)
Text("\(changePercent > 0 ? "+" : "")\(changePercent, format: .percent)")
.foregroundColor(changePercent > 0 ? .green : .red)
}
}
}@MainActor
@Animatable
struct HeartRateView: View {
var bpm: Double
@AnimatableIgnored
var timestamp: Date
var body: some View {
VStack {
Text("\(Int(bpm))")
.font(.system(size: 60, weight: .bold))
Text("BPM")
.font(.caption)
.foregroundColor(.secondary)
}
}
}@MainActor
@Animatable
struct ScoreView: View {
var score: Float
var multiplier: Float
var body: some View {
HStack {
Text("\(Int(score))")
.font(.largeTitle)
Text("×\(multiplier, format: .number.precision(.fractionLength(1)))")
.font(.title2)
.foregroundColor(.orange)
}
}
}@MainActor
@Animatable
struct TimerView: View {
var remainingSeconds: Double
var body: some View {
let minutes = Int(remainingSeconds) / 60
let seconds = Int(remainingSeconds) % 60
Text(String(format: "%02d:%02d", minutes, seconds))
.font(.system(.largeTitle, design: .monospaced))
}
}struct ContentView: View {
@State private var stockPrice: Double = 142.50
var body: some View {
VStack(spacing: 20) {
StockPriceView(price: stockPrice, changePercent: 0.025)
.animation(.spring(duration: 0.8), value: stockPrice)
Button("Simulate Price Change") {
stockPrice = Double.random(in: 130...160)
}
.buttonStyle(.borderedProminent)
}
.padding()
}
}
@MainActor
@Animatable
struct StockPriceView: View {
var price: Double
var changePercent: Double
var body: some View {
VStack(alignment: .trailing) {
Text("$\(price, format: .number.precision(.fractionLength(2)))")
.font(.title)
.fontWeight(.semibold)
Text("\(changePercent > 0 ? "+" : "")\(changePercent, format: .percent.precision(.fractionLength(2)))")
.font(.subheadline)
.foregroundColor(changePercent > 0 ? .green : .red)
}
}
}.animation(.linear) // Constant speed
.animation(.easeIn) // Starts slow, ends fast
.animation(.easeOut) // Starts fast, ends slow
.animation(.easeInOut) // Slow start and end, fast middlelet customCurve = UnitCurve(
startControlPoint: CGPoint(x: 0.2, y: 0),
endControlPoint: CGPoint(x: 0.8, y: 1)
)
.animation(.timingCurve(customCurve, duration: 0.5)).animation(.easeInOut(duration: 0.3))
.animation(.linear(duration: 1.0)).animation(.smooth) // No bounce (default since iOS 17)
.animation(.snappy) // Small amount of bounce
.animation(.bouncy) // Larger amount of bounce.animation(.spring(duration: 0.6, bounce: 0.3))durationbounce.animation(.spring.delay(0.5)).animation(.easeInOut.repeatCount(3, autoreverses: true))
.animation(.linear.repeatForever(autoreverses: false)).animation(.spring.speed(2.0)) // 2x faster
.animation(.spring.speed(0.5)) // 2x slowerwithAnimation {
// Used timing curve by default
}withAnimation {
// Uses .smooth spring by default
}Button("Scale Up") {
withAnimation(.spring) {
scale = 1.5
}
}withAnimationwithAnimation(.spring(duration: 0.6, bounce: 0.4)) {
isExpanded.toggle()
}withAnimation(nil) {
// Changes happen immediately, no animation
resetState()
}Circle()
.fill(isActive ? .blue : .gray)
.animation(.spring, value: isActive)isActiveCircle()
.scaleEffect(scale)
.animation(.bouncy, value: scale)
.opacity(opacity)
.animation(.easeInOut, value: opacity)struct AvatarView: View {
var selected: Bool
var body: some View {
Image("avatar")
.scaleEffect(selected ? 1.5 : 1.0)
.animation(.spring, value: selected)
// ⚠️ If image also changes when selected changes,
// image transition gets animated too (accidental)
}
}struct AvatarView: View {
var selected: Bool
var body: some View {
Image("avatar")
.animation(.spring, value: selected) {
$0.scaleEffect(selected ? 1.5 : 1.0)
}
// ✅ Only scaleEffect animates, image transition doesn't
}
}struct AvatarTappedKey: TransactionKey {
static let defaultValue: Bool = false
}
extension Transaction {
var avatarTapped: Bool {
get { self[AvatarTappedKey.self] }
set { self[AvatarTappedKey.self] = newValue }
}
}var transaction = Transaction()
transaction.avatarTapped = true
withTransaction(transaction) {
isSelected.toggle()
}.transaction { transaction in
if transaction.avatarTapped {
transaction.animation = .bouncy
} else {
transaction.animation = .smooth
}
}protocol CustomAnimation {
// Calculate current value
func animate<V: VectorArithmetic>(
value: V,
time: TimeInterval,
context: inout AnimationContext<V>
) -> V?
// Optional: Should this animation merge with previous?
func shouldMerge<V>(previous: Animation, value: V, time: TimeInterval, context: inout AnimationContext<V>) -> Bool
// Optional: Current velocity
func velocity<V: VectorArithmetic>(
value: V,
time: TimeInterval,
context: AnimationContext<V>
) -> V?
}struct LinearAnimation: CustomAnimation {
let duration: TimeInterval
func animate<V: VectorArithmetic>(
value: V, // Delta vector: target - current
time: TimeInterval, // Elapsed time since animation started
context: inout AnimationContext<V>
) -> V? {
// Animation is done when time exceeds duration
if time >= duration {
return nil
}
// Calculate linear progress (0.0 to 1.0)
let progress = time / duration
// Scale the delta vector by progress
// This returns how much to move FROM current position
// NOT the final target position
return value.scaled(by: progress)
}
}value10.0100.0animate()90.0return value.scaled(by: 0.5)45.010.0 + 45.0 = 55.0// ❌ WRONG: Treating value as the target
let progress = time / duration
return value.scaled(by: progress) // This assumes value is delta
// ❌ WRONG: Trying to interpolate manually
let target = value // No! value is already the delta
return current + (target - current) * progress // Incorrect
// ✅ CORRECT: Scale the delta
return value.scaled(by: progress) // SwiftUI handles the additionfunc shouldMerge(...) -> Bool {
return false // Default implementation
}func shouldMerge(...) -> Bool {
return true // Springs override this
}NavigationLink {
BraceletEditor(bracelet)
.navigationTransition(.zoom(sourceID: bracelet.id, in: namespace))
} label: {
BraceletPreview(bracelet)
}BraceletPreview(bracelet)
.matchedTransitionSource(id: bracelet.id, in: namespace)struct BraceletListView: View {
@Namespace private var braceletList
let bracelets: [Bracelet]
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150))]) {
ForEach(bracelets) { bracelet in
NavigationLink {
BraceletEditor(bracelet: bracelet)
.navigationTransition(
.zoom(sourceID: bracelet.id, in: braceletList)
)
} label: {
BraceletPreview(bracelet: bracelet)
}
.matchedTransitionSource(id: bracelet.id, in: braceletList)
}
}
}
}
}
}func showEditor(for bracelet: Bracelet) {
let braceletEditor = BraceletEditorViewController(bracelet: bracelet)
// Step 1: Specify zoom transition on the pushed view controller
braceletEditor.preferredTransition = .zoom { context in
// Step 2: Return the source view
let editor = context.zoomedViewController as! BraceletEditorViewController
return self.cell(for: editor.bracelet)
}
navigationController?.pushViewController(braceletEditor, animated: true)
}braceletEditor.preferredTransition = .zoom { context in
let editor = context.zoomedViewController as! BraceletEditorViewController
// Use current bracelet, not the one captured at push time
return self.cell(for: editor.bracelet)
}fullScreenCoversheet.fullScreenCover(item: $selectedBracelet) { bracelet in
BraceletEditor(bracelet: bracelet)
.navigationTransition(.zoom(sourceID: bracelet.id, in: namespace))
}.matchedTransitionSource(id: bracelet.id, in: namespace) { source in
source
.cornerRadius(8.0)
.shadow(radius: 4)
}Disappeared → [viewWillAppear] → Appearing → [viewIsAppearing] → [viewDidAppear] → AppearedAppearing → Appeared → Disappearing → ...// ❌ DON'T: Block actions during transitions
func handleTap() {
guard !isTransitioning else { return } // Don't do this
pushViewController(...)
}
// ✅ DO: Always allow the action
func handleTap() {
pushViewController(...) // System handles overlapping transitions
}viewDidAppearviewDidDisappearAnimation@MainActor static func animate(
_ animation: Animation,
changes: () -> Void,
completion: (() -> Void)? = nil
)// Old way: Describe spring in parameters
UIView.animate(withDuration: 0.5,
delay: 0,
usingSpringWithDamping: 0.7,
initialSpringVelocity: 0.5) {
bead.center = endOfBracelet
}
// New way: Use SwiftUI Animation type
UIView.animate(.spring(duration: 0.5)) {
bead.center = endOfBracelet
}// Timing curves
UIView.animate(.linear(duration: 0.3)) { ... }
UIView.animate(.easeIn(duration: 0.3)) { ... }
UIView.animate(.easeOut(duration: 0.3)) { ... }
UIView.animate(.easeInOut(duration: 0.3)) { ... }
// Springs
UIView.animate(.spring) { ... }
UIView.animate(.spring(duration: 0.6, bounce: 0.3)) { ... }
UIView.animate(.smooth) { ... }
UIView.animate(.snappy) { ... }
UIView.animate(.bouncy) { ... }
// Repeating
UIView.animate(.linear(duration: 1.3).repeatForever()) { ... }
// Custom animations
UIView.animate(myCustomAnimation) { ... }| Old UIKit API | New SwiftUI Animation API |
|---|---|
Generates a | No |
| Animation added to layer | Animates presentation values directly |
Animation in layer's | Presentation values in presentation layer |
class BeadViewController: UIViewController {
private var animatingView: UIImageView!
override func viewDidLoad() {
super.viewDidLoad()
animatingView = UIImageView(image: UIImage(systemName: "circle.fill"))
animatingView.tintColor = .systemPink
animatingView.frame = CGRect(x: 0, y: 0, width: 80, height: 80)
view.addSubview(animatingView)
animatingView.center = view.center
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
startAnimating()
}
private func startAnimating() {
let animation = Animation
.linear(duration: 1.3)
.repeatForever()
UIView.animate(animation) { [weak self] in
self?.animatingView.transform = CGAffineTransform(scaleX: 2, y: 2)
}
}
}struct BeadBoxWrapper: UIViewRepresentable {
@Binding var isOpen: Bool
func updateUIView(_ box: BeadBox, context: Context) {
// ❌ Animation on binding doesn't affect UIKit
box.lid.center.y = isOpen ? -100 : 100
}
}
// Usage
BeadBoxWrapper(isOpen: $isOpen)
.animation(.spring, value: isOpen) // No effect on UIKit viewcontext.animate()struct BeadBoxWrapper: UIViewRepresentable {
@Binding var isOpen: Bool
func makeUIView(context: Context) -> BeadBox {
BeadBox()
}
func updateUIView(_ box: BeadBox, context: Context) {
// ✅ Bridges animation from Transaction to UIKit
context.animate {
box.lid.center.y = isOpen ? -100 : 100
}
}
}Transactioncontext.animate()context.animate {
// Changes here
} completion: {
// Called when animation completes
// If not animated, called immediately inline
}// Old way: Manual velocity computation
func handlePan(_ gesture: UIPanGestureRecognizer) {
switch gesture.state {
case .changed:
bead.center = gesture.location(in: view)
case .ended:
let velocity = gesture.velocity(in: view)
let distance = endOfBracelet.distance(to: bead.center)
// 😫 Convert to unit velocity manually
let unitVelocity = CGVector(
dx: velocity.x / distance,
dy: velocity.y / distance
)
UIView.animate(withDuration: 0.5,
delay: 0,
usingSpringWithDamping: 0.7,
initialSpringVelocity: unitVelocity.length) {
bead.center = endOfBracelet
}
}
}// New way: Automatic velocity preservation
func handlePan(_ gesture: UIPanGestureRecognizer) {
switch gesture.state {
case .changed:
// Interactive spring during drag
UIView.animate(.interactiveSpring) {
bead.center = gesture.location(in: view)
}
case .ended:
// Final spring uses velocity from interactiveSprings
UIView.animate(.spring) {
bead.center = endOfBracelet
}
}
}[Drag starts]
↓
[.changed] → interactiveSpring animation (retargets previous)
↓
[.changed] → interactiveSpring animation (retargets previous)
↓
[.changed] → interactiveSpring animation (retargets previous)
↓
[.ended] → .spring animation inherits velocity from interactiveSprings
↓
[Smooth deceleration to final position]struct DraggableBead: View {
@State private var position: CGPoint = .zero
@State private var isDragging = false
var body: some View {
Circle()
.position(position)
.gesture(
DragGesture()
.onChanged { value in
withAnimation(.interactiveSpring) {
position = value.location
}
}
.onEnded { value in
withAnimation(.spring) {
position = targetPosition
}
}
)
}
}.scaleEffect(scale)
.opacity(opacity)
.rotationEffect(angle)body@MainActor
@Animatable
struct MyView: View {
var value: Double
var animatableData: Double {
get { value }
set { value = newValue }
}
var body: some View {
// Called every frame! (main thread)
}
}// User taps, scale changes from 1.0 to 1.5
.scaleEffect(isSelected ? 1.5 : 1.0)@State private var count: Int = 0 // ❌ Int doesn't animate
// Solution
@State private var count: Double = 0 // ✅ Double animates
Text("\(Int(count))") // Display as Int// ❌ No animation specified
Text("\(value)")
// ✅ Add animation
Text("\(value)")
.animation(.spring, value: value)struct ProgressView: View {
@State private var progress: Double = 0
@State private var title: String = "Loading"
var body: some View {
VStack {
Text(title)
ProgressBar(value: progress)
}
.animation(.spring, value: title) // ❌ Animates when title changes, not progress
}
}
// Solution
.animation(.spring, value: progress) // ✅// ❌ Missing Animatable conformance
struct MyView: View {
var value: Double
var body: some View { ... }
}
// ✅ Add @Animatable macro (iOS 26+)
@MainActor
@Animatable
struct MyView: View {
var value: Double
var body: some View { ... }
}
// ✅ OR manual conformance (iOS 13+)
struct MyView: View, Animatable {
var value: Double
var animatableData: Double {
get { value }
set { value = newValue }
}
var body: some View { ... }
}@MainActor
@Animatable
struct ExpensiveView: View {
var value: Double
var animatableData: Double {
get { value }
set { value = newValue }
}
var body: some View {
// ❌ Called every frame!
let heavyComputation = performExpensiveWork(value)
return Text("\(heavyComputation)")
}
}struct OptimizedView: View {
@State private var value: Double = 0
var body: some View {
Text("\(computeOnce(value))")
.opacity(value) // ✅ Built-in effect, off-main-thread
}
}// ❌ Spring merges with previous animation
withAnimation(.spring) {
scale = 1.0
}
// ✅ Timing curve starts fresh (additive, no merge)
withAnimation(.easeInOut(duration: 0.5)) {
scale = 1.0
}