ai/ios-skills/ios-axiom-uikit-animation-debugging/SKILL.md
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
npx skillsauth add kurko/dotfiles axiom-uikit-animation-debuggingInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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
data-ai
Merge the current worktree branch into main and sync main back. Use when the user says "merge to main", "ship it", "merge and continue", or after completing a task in a worktree and wanting to continue with the next one.
tools
Synchronize AI agent skills, commands, configs, permissions, hooks, and instructions across Claude Code, Codex CLI, and other Agent Skills-compatible tools. Use when the user asks to pull skills from Claude into Codex, sync Codex work back to Claude, migrate agent commands, reconcile frontmatter, update permissions, or keep agent setup files in parity.
testing
Write or update UI-independent use cases for QA. Use when the user says "write use cases", "add use cases", "QA use cases", "update use cases", "compose use cases", or when starting implementation of a new feature (after plan approval). Also activates for "what should we test", "regression cases", or "use cases for QA".
documentation
Skill on how to write a task. Use when user asks you to write a task (for Asana, Linear, Jira, Notion and equivalent). Also activates when user says "create task", "write task", or similar task creation workflow requests.