| 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:
- 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 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
- 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
ProcessInfo.processInfo.processorCountor 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