Add scan flow MVP and local Axiom skill workspace
This snapshot establishes the camera-to-result recognition flow and related tests while checking in the project skill/docs assets required for the configured local tooling.
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"source": "CharlesWiltgen/Axiom",
|
||||
"sourceType": "git",
|
||||
"repoUrl": "https://github.com/CharlesWiltgen/Axiom",
|
||||
"subpath": "axiom-codex/skills/axiom-uikit-animation-debugging",
|
||||
"installedAt": "2026-04-12T08:06:55.655Z"
|
||||
}
|
||||
465
.claude/skills/axiom-uikit-animation-debugging/SKILL.md
Normal file
465
.claude/skills/axiom-uikit-animation-debugging/SKILL.md
Normal file
@@ -0,0 +1,465 @@
|
||||
---
|
||||
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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
interface:
|
||||
display_name: "UIKit Animation Debugging"
|
||||
short_description: "CAAnimation completion handler doesn't fire, spring physics look wrong on device, animation duration mismatches actua..."
|
||||
Reference in New Issue
Block a user