axiom-uikit-animation-debugging
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-uikit-animation-debugging
Agent 安装分布
Skill 文档
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