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
Inherits all available tools
Additional assets for this skill
This skill inherits all available tools. When active, it can use any tool Claude has access to.
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.
If you see ANY of these, suspect animation logic not device behavior:
[weak self] in completion handler and you're not sure whyCritical 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.
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
Before changing ANY code, you must identify which ONE diagnostic is the root cause:
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
Always start with Pattern 1 (Completion Handler Basics)
Then Pattern 2 (CATransaction duration mismatch)
Then Pattern 3 (isRemovedOnCompletion)
Patterns 4-7 Apply based on specific symptom (see Decision Tree line 91+)
layer.add(animation, forKey: "myAnimation")
animation.completion = { finished in // ā Too late!
print("Done")
}
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.
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
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.
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
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.
anim.completion = { finished in
self.property = "value" // ā GUARANTEED retain cycle
}
anim.completion = { [weak self] finished in
guard let self = self else { return }
self.property = "value" // Safe to access
}
// 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!
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.
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
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.
let springAnim = CASpringAnimation()
springAnim.damping = 0.7 // Hardcoded for iPhone 15 Pro
springAnim.stiffness = 100
layer.add(springAnim, forKey: nil) // Janky on iPhone 12
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.
| 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 |
If you've spent >30 minutes and the animation is still broken:
ā Setting completion handler AFTER adding animation
layer.add()ā Assuming simulator timing = device timing
ā Hardcoding device-specific values
ProcessInfo.processInfo.processorCount or test classā Wrapping animation in CATransaction.setAnimationDuration()
ā FORBIDDEN: Using strong self in completion handler
[weak self] with guardā Not removing old animation before adding new
layer.removeAnimation(forKey:) first or use unique keysā Ignoring layer.speed and layer.timeOffset
Before CAAnimation debugging 2-4 hours per issue
After 15-30 minutes with systematic diagnosis
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