Claude Code Plugins

Community-maintained marketplace

Feedback

uikit-animation-debugging

@CharlesWiltgen/Axiom
55
0

Use when CAAnimation completion handler doesn't fire, spring physics look wrong on device, animation duration mismatches actual time, gesture + animation interaction causes jank, or timing differs between simulator and real hardware - systematic CAAnimation diagnosis with CATransaction patterns, frame rate awareness, and device-specific behavior

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name uikit-animation-debugging
description Use when CAAnimation completion handler doesn't fire, spring physics look wrong on device, animation duration mismatches actual time, gesture + animation interaction causes jank, or timing differs between simulator and real hardware - systematic CAAnimation diagnosis with CATransaction patterns, frame rate awareness, and device-specific behavior
skill_type discipline
version 1.0.0

UIKit Animation Debugging

Overview

CAAnimation issues manifest as missing completion handlers, wrong timing, or jank under specific conditions. Core principle 90% of CAAnimation problems are CATransaction timing, layer state, or frame rate assumptions, not Core Animation bugs.

Red Flags — Suspect CAAnimation Issue

If you see ANY of these, suspect animation logic not device behavior:

  • Completion handler fires on simulator but not device
  • Animation duration (0.5s) doesn't match visual duration (1.2s)
  • Spring animation looks correct on iPhone 15 Pro but janky on older devices
  • Gesture + animation together causes stuttering (fine separately)
  • [weak self] in completion handler and you're not sure why
  • FORBIDDEN Hardcoding duration/values to "match what actually happens"
    • This ships device-specific bugs to users on different hardware
    • Do not rationalize this as a "temporary fix" or "good enough"

Critical distinction Simulator often hides timing issues (60Hz only, no throttling). Real devices expose them (variable frame rate, CPU throttling, background pressure). MANDATORY: Test on real device (oldest supported model) before shipping.

Mandatory First Steps

ALWAYS run these FIRST (before changing code):

// 1. Check if completion is firing at all
animation.completion = { [weak self] finished in
    print("🔥 COMPLETION FIRED: finished=\(finished)")
    guard let self = self else {
        print("🔥 SELF WAS NIL")
        return
    }
    // original code
}

// 2. Check actual duration vs declared
let startTime = Date()
let anim = CABasicAnimation(keyPath: "position.x")
anim.duration = 0.5  // Declared
layer.add(anim, forKey: "test")

DispatchQueue.main.asyncAfter(deadline: .now() + 0.51) {
    print("Elapsed: \(Date().timeIntervalSince(startTime))")  // Actual
}

// 3. Check what animations are active
if let keys = layer.animationKeys() {
    print("Active animations: \(keys)")
    for key in keys {
        if let anim = layer.animation(forKey: key) {
            print("\(key): duration=\(anim.duration), removed=\(anim.isRemovedOnCompletion)")
        }
    }
}

// 4. Check layer state
print("Layer speed: \(layer.speed)")  // != 1.0 means timing is scaled
print("Layer timeOffset: \(layer.timeOffset)")  // != 0 means animation is offset

What this tells you

  • Completion print appears → Handler fires, issue is in callback code
  • Completion print missing → Handler not firing, check CATransaction/layer state
  • Elapsed time == declared → Duration is correct, visual jank is from frames
  • Elapsed time != declared → CATransaction wrapping is changing duration
  • layer.speed != 1.0 → Something is slowing animation
  • Active animations list is long → Multiple animations competing

MANDATORY INTERPRETATION

Before changing ANY code, you must identify which ONE diagnostic is the root cause:

  1. If completion fires but elapsed time != declared duration → Apply Pattern 2 (CATransaction)
  2. If completion doesn't fire AND isRemovedOnCompletion is true → Apply Pattern 3
  3. If completion fires but visual is janky → MUST profile with Instruments first
    • You cannot guess "it's probably frames" - prove it with data
    • Profile > Core Animation instrument shows frame drops with certainty
    • If you skip Instruments, you're guessing

If diagnostics are contradictory or unclear

  • STOP. Do NOT proceed to patterns yet
  • Add more print statements to narrow the cause
  • Ask: "The diagnostics show X and Y but Z doesn't match. What am I missing?"
  • Profile with Instruments > Core Animation if unsure

Decision Tree

CAAnimation problem?
├─ Completion handler never fires?
│  ├─ On simulator only?
│  │  └─ Simulator timing is different (60Hz). Test on real device.
│  ├─ On real device only?
│  │  ├─ Check: isRemovedOnCompletion and fillMode
│  │  ├─ Check: CATransaction wrapping
│  │  └─ Check: app goes to background during animation
│  └─ On both simulator and device?
│     ├─ Check: completion handler is set BEFORE adding animation
│     └─ Check: [weak self] is actually captured (not nil before completion)
│
├─ Duration mismatch (declared != visual)?
│  ├─ Is layer.speed != 1.0?
│  │  └─ Something scaled animation duration. Find and fix.
│  ├─ Is animation wrapped in CATransaction?
│  │  └─ CATransaction.setAnimationDuration() overrides animation.duration
│  └─ Is visual duration LONGER than declared?
│     └─ Simulator (60Hz) vs device frame rate (120Hz). Recalculate for real hardware.
│
├─ Spring physics wrong on device?
│  ├─ Are values hardcoded for one device?
│  │  └─ Use device performance class, not model
│  ├─ Are damping/stiffness values swapped with mass/stiffness?
│  │  └─ Check CASpringAnimation parameter meanings
│  └─ Does it work on simulator but not device?
│     └─ Simulator uses 60Hz. Device may use 120Hz. Recalculate.
│
└─ Gesture + animation jank?
   ├─ Are animations competing (same keyPath)?
   │  └─ Remove old animation before adding new
   ├─ Is gesture updating layer while animation runs?
   │  └─ Use CADisplayLink for synchronized updates
   └─ Is gesture blocking the main thread?
      └─ Profile with Instruments > Core Animation

Common Patterns

Pattern Selection Rules (MANDATORY)

Apply ONE pattern at a time, in this order

  1. Always start with Pattern 1 (Completion Handler Basics)

    • If completion NEVER fires → Pattern 1
    • Verify completion is set BEFORE add() with print statement (line 33)
    • Only proceed to Pattern 2 if completion FIRES but timing is wrong
  2. Then Pattern 2 (CATransaction duration mismatch)

    • Only if completion fires but elapsed time != declared duration
    • Check logs from Mandatory First Steps (line 40-47)
  3. Then Pattern 3 (isRemovedOnCompletion)

    • Only if animation completes but visual state reverts
  4. Patterns 4-7 Apply based on specific symptom (see Decision Tree line 91+)

FORBIDDEN

  • ❌ Applying multiple patterns at once ("let me try Pattern 2 AND Pattern 4 together")
  • ❌ Skipping Pattern 1 because "I already know it's not that"
  • ❌ Combining patterns without understanding why each is needed
  • ❌ Trying patterns randomly and hoping one works

Pattern 1: Completion Handler Basics

❌ WRONG (Handler set AFTER adding animation)

layer.add(animation, forKey: "myAnimation")
animation.completion = { finished in  // ❌ Too late!
    print("Done")
}

✅ CORRECT (Handler set BEFORE adding)

animation.completion = { [weak self] finished in
    print("🔥 Animation finished: \(finished)")
    guard let self = self else { return }
    self.doNextStep()
}
layer.add(animation, forKey: "myAnimation")

Why Completion handler must be set before animation is added to layer. Setting after does nothing.


Pattern 2: CATransaction vs animation.duration

❌ WRONG (CATransaction overrides animation duration)

CATransaction.begin()
CATransaction.setAnimationDuration(2.0)  // ❌ Overrides all animations!
let anim = CABasicAnimation(keyPath: "position")
anim.duration = 0.5  // This is ignored
layer.add(anim, forKey: nil)
CATransaction.commit()  // Animation takes 2.0 seconds, not 0.5

✅ CORRECT (Set duration on animation, not transaction)

let anim = CABasicAnimation(keyPath: "position")
anim.duration = 0.5
layer.add(anim, forKey: nil)
// No CATransaction wrapping

Why CATransaction.setAnimationDuration() affects ALL animations in the transaction block. Use it only if you want to change all animations uniformly.


Pattern 3: isRemovedOnCompletion & fillMode

❌ WRONG (Animation disappears after completion)

let anim = CABasicAnimation(keyPath: "opacity")
anim.fromValue = 1.0
anim.toValue = 0.0
anim.duration = 0.5
layer.add(anim, forKey: nil)
// After 0.5s, animation is removed AND layer reverts to original state

✅ CORRECT (Keep animation state)

anim.isRemovedOnCompletion = false
anim.fillMode = .forwards  // Keep final state after animation
layer.add(anim, forKey: nil)
// After 0.5s, animation state is preserved

Why By default, animations are removed and layer reverts. For permanent state changes, set isRemovedOnCompletion = false and fillMode = .forwards.


Pattern 4: Weak Self in Completion (MANDATORY)

❌ FORBIDDEN (Strong self creates retain cycle)

anim.completion = { finished in
    self.property = "value"  // ❌ GUARANTEED retain cycle
}

✅ MANDATORY (Always use weak self)

anim.completion = { [weak self] finished in
    guard let self = self else { return }
    self.property = "value"  // Safe to access
}

Why this is MANDATORY, not optional

  • CAAnimation keeps completion handler alive until animation completes
  • Completion handler captures self strongly (unless explicitly weak)
  • Creates retain cycle: self → animation → completion → self
  • Memory leak occurs even if animation is short-lived (0.3s doesn't prevent it)

FORBIDDEN rationalizations

  • ❌ "Animation is short, so no retain cycle risk"
  • ❌ "I'll remove the animation manually, so it's fine"
  • ❌ "This code path only runs once"

ALWAYS use [weak self] in completion handlers. No exceptions.


Pattern 5: Multiple Animations (Same keyPath)

❌ WRONG (Animations conflict)

// Add animation 1
let anim1 = CABasicAnimation(keyPath: "position.x")
anim1.toValue = 100
layer.add(anim1, forKey: "slide")

// Later, add animation 2
let anim2 = CABasicAnimation(keyPath: "position.x")
anim2.toValue = 200
layer.add(anim2, forKey: "slide")  // ❌ Same key, replaces anim1!

✅ CORRECT (Remove before adding)

layer.removeAnimation(forKey: "slide")  // Remove old first

let anim2 = CABasicAnimation(keyPath: "position.x")
anim2.toValue = 200
layer.add(anim2, forKey: "slide")

Or use unique keys:

let anim1 = CABasicAnimation(keyPath: "position.x")
layer.add(anim1, forKey: "slide_1")

let anim2 = CABasicAnimation(keyPath: "position.x")
layer.add(anim2, forKey: "slide_2")  // Different key

Why Adding animation with same key replaces previous animation. Either remove old animation or use unique keys.


Pattern 6: CADisplayLink for Gesture + Animation Sync

❌ WRONG (Gesture updates directly, animation updates at different rate)

func handlePan(_ gesture: UIPanGestureRecognizer) {
    let translation = gesture.translation(in: view)
    view.layer.position.x = translation.x  // ❌ Syncing issue
}

// Separately:
let anim = CABasicAnimation(keyPath: "position.x")
view.layer.add(anim, forKey: nil)  // Jank from desync

✅ CORRECT (Use CADisplayLink for synchronization)

var displayLink: CADisplayLink?

func startSyncedAnimation() {
    displayLink = CADisplayLink(
        target: self,
        selector: #selector(updateAnimation)
    )
    displayLink?.add(to: .main, forMode: .common)
}

@objc func updateAnimation() {
    // Update gesture AND animation in same frame
    let gesture = currentGesture
    let position = calculatePosition(from: gesture)
    layer.position = position  // Synchronized update
}

Why Gesture recognizer and CAAnimation may run at different frame rates. CADisplayLink syncs both to screen refresh rate.


Pattern 7: Spring Animation Device Differences

❌ WRONG (Hardcoded for one device)

let springAnim = CASpringAnimation()
springAnim.damping = 0.7  // Hardcoded for iPhone 15 Pro
springAnim.stiffness = 100
layer.add(springAnim, forKey: nil)  // Janky on iPhone 12

✅ CORRECT (Adapt to device performance)

let springAnim = CASpringAnimation()

// Use device performance class, not model
if ProcessInfo.processInfo.processorCount >= 6 {
    // Modern A-series (A14+)
    springAnim.damping = 0.7
    springAnim.stiffness = 100
} else {
    // Older A-series
    springAnim.damping = 0.85
    springAnim.stiffness = 80
}

layer.add(springAnim, forKey: nil)

Why Spring physics feel different at 60Hz vs 120Hz. Use device class (core count, GPU) not model.


Quick Reference Table

Issue Check Fix
Completion never fires Set handler BEFORE add() Move completion = before add()
Duration mismatch Is CATransaction wrapping? Remove CATransaction or remove animation from it
Jank on older devices Is value hardcoded? Use ProcessInfo for device class
Animation disappears isRemovedOnCompletion? Set to false, use fillMode = .forwards
Gesture + animation jank Synced updates? Use CADisplayLink
Multiple animations conflict Same key? Use unique keys or removeAnimation() first
Weak self in handler Completion captured correctly? Always use [weak self] in completion

When You're Stuck After 30 Minutes

If you've spent >30 minutes and the animation is still broken:

STOP. You either

  1. Skipped a mandatory step (most common)
  2. Misinterpreted diagnostic output
  3. Applied wrong pattern for your symptom
  4. Are in the 5% edge case requiring Instruments profiling

MANDATORY checklist before claiming "skill didn't work"

  • I ran ALL 4 diagnostic blocks from Mandatory First Steps (lines 28-63)
  • I pasted the EXACT output of diagnostics (logs, print statements)
  • I identified ONE root cause from "What this tells you" (lines 66-72)
  • I applied the FIRST matching pattern from Decision Tree (lines 91+)
  • I tested the pattern on a REAL device, not just simulator
  • I verified the pattern with print statements/logs showing the fix worked

If ALL boxes are checked and still broken

  • You MUST profile with Instruments > Core Animation
  • Time cost: 30-60 minutes (unavoidable for edge cases)
  • Hardcoding, asyncAfter, or "shipping and hoping" are FORBIDDEN
  • Ask for guidance before adding any workarounds

Time cost transparency

  • Pattern 1: 2-5 minutes
  • Pattern 2: 3-5 minutes
  • Instruments profiling: 30-60 minutes (for edge cases only)
  • Trying random fixes without profiling: 2-4 hours + risk of shipping broken

Common Mistakes

Setting completion handler AFTER adding animation

  • Completion is not set in time
  • Fix: Set completion BEFORE layer.add()

Assuming simulator timing = device timing

  • Simulator runs 60Hz, devices run 60Hz-120Hz
  • Fix: Test on real device before tuning duration

Hardcoding device-specific values

  • "This value works on iPhone 15 Pro" → fails on iPhone 12
  • Fix: Use ProcessInfo.processInfo.processorCount or test class

Wrapping animation in CATransaction.setAnimationDuration()

  • Overrides all animation durations in that transaction
  • Fix: Set duration on animation, not transaction

FORBIDDEN: Using strong self in completion handler

  • GUARANTEED retain cycle: self → animation → completion → self
  • Fix: ALWAYS use [weak self] with guard

Not removing old animation before adding new

  • Same keyPath replaces previous animation
  • Fix: layer.removeAnimation(forKey:) first or use unique keys

Ignoring layer.speed and layer.timeOffset

  • These scale animation timing invisibly
  • Fix: Check these values if timing is wrong

Real-World Impact

Before CAAnimation debugging 2-4 hours per issue

  • Print everywhere, test on simulator, hardcode values, ship and hope
  • "Maybe it's a device bug?"
  • DispatchQueue.asyncAfter as fallback timer

After 15-30 minutes with systematic diagnosis

  • Check completion handler setup (2 min)
  • Check CATransaction wrapping (3 min)
  • Check layer state and duration mismatch (5 min)
  • Identify root cause, apply pattern (5 min)
  • Test on real device (varies)

Key insight CAAnimation issues are almost always CATransaction, layer state, or frame rate assumptions, never Core Animation bugs.


Last Updated: 2025-11-30 Status: TDD-tested with pressure scenarios Framework: UIKit CAAnimation