--- name: axiom-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 license: MIT metadata: 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): ```swift // 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) ```swift layer.add(animation, forKey: "myAnimation") animation.completion = { finished in // ❌ Too late! print("Done") } ``` #### ✅ CORRECT (Handler set BEFORE adding) ```swift 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) ```swift 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) ```swift 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) ```swift 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) ```swift 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) ```swift anim.completion = { finished in self.property = "value" // ❌ GUARANTEED retain cycle } ``` #### ✅ MANDATORY (Always use weak self) ```swift 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) ```swift // 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) ```swift 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: ```swift 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) ```swift 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) ```swift 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) ```swift 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) ```swift 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