Claude-skill-registry axiom-uikit-animation-debugging
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
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/axiom-uikit-animation-debugging" ~/.claude/skills/majiayu000-claude-skill-registry-axiom-uikit-animation-debugging && rm -rf "$T"
skills/data/axiom-uikit-animation-debugging/SKILL.mdUIKit 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)
in completion handler and you're not sure why[weak self]- ❌ 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:
- If completion fires but elapsed time != declared duration → Apply Pattern 2 (CATransaction)
- If completion doesn't fire AND isRemovedOnCompletion is true → Apply Pattern 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
-
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
-
Then Pattern 2 (CATransaction duration mismatch)
- Only if completion fires but elapsed time != declared duration
- Check logs from Mandatory First Steps (line 40-47)
-
Then Pattern 3 (isRemovedOnCompletion)
- Only if animation completes but visual state reverts
-
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 | Move before |
| Duration mismatch | Is CATransaction wrapping? | Remove CATransaction or remove animation from it |
| Jank on older devices | Is value hardcoded? | Use for device class |
| Animation disappears | ? | Set to , use |
| Gesture + animation jank | Synced updates? | Use |
| Multiple animations conflict | Same key? | Use unique keys or first |
| Weak self in handler | Completion captured correctly? | Always use in completion |
When You're Stuck After 30 Minutes
If you've spent >30 minutes and the animation is still broken:
STOP. You either
- Skipped a mandatory step (most common)
- Misinterpreted diagnostic output
- Applied wrong pattern for your symptom
- 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
or test classProcessInfo.processInfo.processorCount
❌ 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
with guard[weak self]
❌ Not removing old animation before adding new
- Same keyPath replaces previous animation
- Fix:
first or use unique keyslayer.removeAnimation(forKey:)
❌ 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