From kiln
Comprehensive audit of gesture-driven animations for iOS 18+ (keyframeAnimator, PhaseAnimator, gesture velocity, interruptible animations, spring physics, haptic synchronization)
npx claudepluginhub moonlightbyte/kiln# Gesture-Driven Animation Audit (iOS 18+) Comprehensive audit of gesture-driven animations using modern SwiftUI patterns including keyframeAnimator, PhaseAnimator, gesture velocity, interruptible animations, spring physics, and haptic feedback synchronization. ## Overview iOS 18+ enables powerful gesture-driven animations through: - **Gesture velocity tracking** - DragGesture.Value.velocity provides precise speed/direction - **keyframeAnimator** - Plays keyframe animations on trigger changes; automatically interruptible by user gestures - **PhaseAnimator** - Loops through animation phas...
Comprehensive audit of gesture-driven animations using modern SwiftUI patterns including keyframeAnimator, PhaseAnimator, gesture velocity, interruptible animations, spring physics, and haptic feedback synchronization.
iOS 18+ enables powerful gesture-driven animations through:
This audit is based on:
When using DragGesture.Value.velocity, map the 2D velocity vector to animation duration:
// velocity in points per second
let speedPts = sqrt(pow(gesture.velocity.width, 2) + pow(gesture.velocity.height, 2))
// Map to animation duration (300-1000ms range)
let baseDuration = 0.3 // 300ms minimum
let durationFromVelocity = max(baseDuration, min(1.0, distance / speedPts))
// Use in animation
withAnimation(.easeOut(duration: durationFromVelocity)) {
position = targetPosition
}
Key Points:
keyframeAnimator automatically stops when user performs gesture:
@State private var isExpanded = false
var body: some View {
Rectangle()
.keyframeAnimator(initialValue: 0, trigger: isExpanded) { content, phase in
content
.scaleEffect(phase.value) // Animates from 0 to 1
} keyframes: { _ in
KeyframeTrack(\.self) {
LinearKeyframe(0, duration: 0)
SpringKeyframe(1, duration: 0.6, timingCurve: .easeInOut)
}
}
.gesture(
DragGesture()
.onChanged { gesture in
// User drag interrupts keyframe animation automatically
// No explicit cancellation needed
}
)
}
Critical Fact: If user performs gesture during keyframe animation, the animation stops and user has full control. No additional code needed for interruption.
PhaseAnimator loops through phases and stops on gesture:
@State private var phases: [Int] = Array(0...3)
@State private var showAnimation = true
var body: some View {
Image(systemName: "heart.fill")
.phaseAnimator(phases, trigger: showAnimation) { content, phase in
content
.scaleEffect(1.0 + CGFloat(phase) * 0.2)
.opacity(phase == 0 ? 0.5 : 1.0)
} animation: { phase in
.easeInOut(duration: 0.3)
}
.gesture(
TapGesture()
.onEnded { _ in
// Tap stops phase animation automatically
showAnimation.toggle()
}
)
}
Key: PhaseAnimator pauses/resumes based on trigger state. Gesture can change trigger to stop animation.
Spring animations initialized with gesture velocity for natural momentum:
@State private var offset: CGFloat = 0
@State private var isDragging = false
var body: some View {
Rectangle()
.offset(x: offset)
.gesture(
DragGesture()
.onChanged { gesture in
isDragging = true
offset = gesture.translation.width
}
.onEnded { gesture in
isDragging = false
// Spring animation with gesture velocity
let velocity = gesture.velocity.width
let targetOffset: CGFloat = velocity > 0 ? 300 : -300
withAnimation(
.spring(
response: 0.5,
dampingRatio: 0.7,
blendDuration: 0.2
)
) {
offset = targetOffset
}
// If you need custom velocity handling:
// Use CASpringAnimation with initialVelocity
}
)
}
Note: SwiftUI's .spring() modifier doesn't directly accept gesture velocity. For advanced velocity-aware springs, use CASpringAnimation or custom transaction with tracksVelocity.
Drag that transitions to spring animation on release:
@State private var dragOffset: CGFloat = 0
@State private var finalPosition: CGFloat = 0
var body: some View {
Rectangle()
.offset(x: dragOffset == 0 ? finalPosition : dragOffset)
.gesture(
DragGesture()
.onChanged { gesture in
// Disable animation during drag for immediate feedback
withAnimation(.none) {
dragOffset = gesture.translation.width
}
}
.onEnded { gesture in
// Calculate final position based on drag
let threshold: CGFloat = 50
finalPosition = abs(dragOffset) > threshold ?
(dragOffset > 0 ? 300 : -300) : 0
// Transition to spring animation
dragOffset = 0 // Reset drag
withAnimation(.spring(response: 0.5, dampingRatio: 0.7)) {
// Spring animation active once dragOffset reset
}
}
)
}
All SwiftUI animations are interruptible by default. Changing a state mid-animation smoothly redirects:
@State private var isExpanded = false
var body: some View {
Rectangle()
.frame(width: isExpanded ? 300 : 100)
.animation(.easeInOut(duration: 0.5), value: isExpanded)
.gesture(
TapGesture()
.onEnded { _ in
isExpanded.toggle() // Smoothly redirect mid-animation
}
)
}
Key: Tapping during expansion smoothly reverses to collapse. No special code needed—it's the default behavior.
simultaneousGesture: Both gestures recognize together (useful for haptic + drag):
Rectangle()
.gesture(
DragGesture()
.onChanged { gesture in
position = gesture.translation
}
)
.simultaneousGesture(
// Haptic fires while dragging (doesn't interfere with drag)
DragGesture()
.onChanged { gesture in
if gesture.translation.width > 50 {
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
}
}
)
Exclusive gesture: Only one gesture recognizes (default):
Rectangle()
.gesture(
TapGesture()
.onEnded { _ in doSomething() }
)
.gesture(
// This gesture is exclusive—only one fires
LongPressGesture()
.onEnded { _ in doOtherThing() }
)
Coordinate multiple gesture phases with @GestureState:
@GestureState private var isPressed = false
@State private var isConfirmed = false
var body: some View {
Rectangle()
.foregroundColor(isPressed ? .gray : .blue)
.scaleEffect(isPressed ? 0.9 : 1.0)
.gesture(
LongPressGesture(minimumDuration: 1.0)
.updating($isPressed) { value, state, _ in
state = value // Updates during press
}
.onEnded { _ in
isConfirmed = true // Persists after gesture
}
)
}
@GestureState: Resets to initial value when gesture ends (temporary feedback) @State: Persists after gesture ends (permanent effect)
Sync haptics with animation milestones:
@State private var scale: CGFloat = 1.0
var body: some View {
Circle()
.scaleEffect(scale)
.gesture(
TapGesture()
.onEnded { _ in
// Haptic fires immediately on gesture recognition
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
// Animation starts with haptic for synchronized feel
withAnimation(.spring(response: 0.5, dampingRatio: 0.6)) {
scale = 1.5
}
// Schedule second haptic at animation midpoint
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
}
)
}
Best Practice: Haptic on gesture recognition (immediate feedback) + optional haptics at animation milestones.
Always check accessibilityReduceMotion for gesture-driven animations:
@Environment(\.accessibilityReduceMotion) var reduceMotion
var body: some View {
Rectangle()
.gesture(
DragGesture()
.onEnded { gesture in
let animation = reduceMotion ?
Animation.none : // Instant position change
.spring(response: 0.5, dampingRatio: 0.7)
withAnimation(animation) {
position = targetPosition
}
}
)
}
{{#if (or (eq focus "velocity-mapping") (eq focus "all") (not focus))}}
{{#if (or (eq focus "interruption") (eq focus "all") (not focus))}}
.animation() modifier (not Core Animation){{#if (or (eq focus "keyframe-animator") (eq focus "all") (not focus))}}
{{#if (or (eq focus "phase-animator") (eq focus "all") (not focus))}}
{{#if (or (eq focus "spring-physics") (eq focus "all") (not focus))}}
{{#if (or (eq focus "drag-continuation") (eq focus "all") (not focus))}}
.none animation{{#if (or (eq focus "simultaneous-gesture") (eq focus "all") (not focus))}}
{{#if (or (eq focus "interruption") (eq focus "all") (not focus))}}
{{#if (or (eq focus "state-machine") (eq focus "all") (not focus))}}
{{#if (or (eq focus "haptic-sync") (eq focus "all") (not focus))}}
{{#if (or (eq focus "reduce-motion") (eq focus "all") (not focus))}}
{{#if (or (eq focus "all") (not focus))}}
{{#if (or (eq focus "all") (not focus))}}
@State private var cardOffset: CGFloat = 0
@State private var cardVisible = true
var body: some View {
ZStack {
if cardVisible {
RoundedRectangle(cornerRadius: 12)
.fill(Color.blue)
.frame(height: 300)
.offset(y: cardOffset)
.gesture(
DragGesture()
.onChanged { gesture in
withAnimation(.none) {
cardOffset = gesture.translation.height
}
}
.onEnded { gesture in
let distance = abs(cardOffset)
let velocity = gesture.velocity.height
// Calculate animation duration based on velocity
let speedPts = abs(velocity)
let baseDuration = 0.3
let duration = speedPts > 0 ?
max(0.2, min(1.0, distance / speedPts)) : baseDuration
if distance > 150 {
// Fling dismiss
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
withAnimation(.easeOut(duration: duration)) {
cardOffset = gesture.translation.height > 0 ? 500 : -500
}
DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
cardVisible = false
}
} else {
// Snap back
UIImpactFeedbackGenerator(style: .light).impactOccurred()
withAnimation(.spring(response: 0.4, dampingRatio: 0.8)) {
cardOffset = 0
}
}
}
)
}
}
}
@State private var isLiked = false
var body: some View {
Image(systemName: "heart.fill")
.font(.system(size: 40))
.foregroundColor(.red)
.keyframeAnimator(initialValue: CGFloat(1.0), trigger: isLiked) { content, value in
content
.scaleEffect(value)
.opacity(value >= 0.8 ? 1.0 : 0.5)
} keyframes: { _ in
KeyframeTrack(\.self) {
LinearKeyframe(1.0, duration: 0)
SpringKeyframe(1.3, duration: 0.3, timingCurve: .easeOut)
LinearKeyframe(1.1, duration: 0.2)
SpringKeyframe(1.0, duration: 0.3, timingCurve: .easeInOut)
}
}
.gesture(
TapGesture()
.onEnded { _ in
// Gesture interrupts keyframe animation automatically
// No explicit cancellation code needed
isLiked.toggle()
}
)
}
Note: Tapping during keyframe animation stops it instantly. User has full control.
@State private var offset: CGFloat = 0
@State private var isDragging = false
var body: some View {
Circle()
.fill(Color.purple)
.frame(width: 80)
.offset(x: offset)
.gesture(
DragGesture()
.onChanged { gesture in
isDragging = true
withAnimation(.none) {
offset = gesture.translation.width
}
}
.onEnded { gesture in
isDragging = false
let threshold: CGFloat = 50
let targetOffset: CGFloat = abs(offset) > threshold ?
(offset > 0 ? 300 : -300) : 0
// Spring animation with momentum feel
let velocity = gesture.velocity.width
let response = 0.5
let damping = 0.7
withAnimation(.spring(response: response, dampingRatio: damping)) {
offset = targetOffset
}
}
)
}
@State private var scale: CGFloat = 1.0
var body: some View {
Button(action: {}) {
Image(systemName: "star.fill")
.font(.title)
.scaleEffect(scale)
}
.gesture(
TapGesture()
.onEnded { _ in
// Immediate haptic feedback
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
// Animation with synchronized haptic milestone
withAnimation(.spring(response: 0.4, dampingRatio: 0.6)) {
scale = 1.3
}
// Haptic at peak scale (animation midpoint ~200ms)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
// Reset scale
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
withAnimation(.easeInOut(duration: 0.2)) {
scale = 1.0
}
}
}
)
}
@Environment(\.accessibilityReduceMotion) var reduceMotion
@State private var isExpanded = false
var body: some View {
VStack {
if isExpanded {
Text("Expanded content")
.transition(.opacity)
}
}
.gesture(
TapGesture()
.onEnded { _ in
if reduceMotion {
// Instant change, no animation
isExpanded.toggle()
} else {
// Smooth animation
withAnimation(.easeInOut(duration: 0.3)) {
isExpanded.toggle()
}
}
}
)
}
Use only these properties in gesture animations:
offset(x:y:) - Transform (GPU accelerated)scaleEffect() - Transform (GPU accelerated)rotationEffect() - Transform (GPU accelerated)opacity() - GPU acceleratedAvoid during drag:
| Operation | Budget | Notes |
|---|---|---|
| onChanged callback | < 2ms | Called 60x per second during drag |
| Gesture recognition | < 5ms | Initial tap/drag detection |
| Animation frame | < 16ms | 60fps budget = 16.7ms per frame |
| Haptic generation | < 5ms | Non-blocking |
// BAD: Heavy computation in onChanged
.onChanged { gesture in
let expensiveCalculation = computeSomethingExpensive() // SLOW
offset = gesture.translation.width
}
// GOOD: Pre-compute or defer
.onChanged { gesture in
offset = gesture.translation.width // Instant feedback
// Schedule expensive work separately
DispatchQueue.main.async {
let result = computeSomethingExpensive()
// Use result later
}
}
Symptom: Frame drops when dragging Cause: Layout recalculation, heavy computation in onChanged Fix: Use transform properties only (offset, scale), move heavy work off gesture thread
// BAD
.onChanged { gesture in
size = gesture.translation.width * 2 // Layout change = jank
}
// GOOD
.onChanged { gesture in
offset = gesture.translation.width
}
Symptom: Multiple buzzes from single gesture Cause: Haptic fired in onChanged (called continuously) Fix: Fire haptic only in onEnded or use guard
// BAD
.onChanged { gesture in
UIImpactFeedbackGenerator(style: .light).impactOccurred() // Fires 60x/sec
}
// GOOD
.onEnded { gesture in
UIImpactFeedbackGenerator(style: .light).impactOccurred() // Fires once
}
Symptom: Animation continues after user taps
Cause: Not using interruptible animation modifier
Fix: Use .animation() modifier instead of Task
// BAD
.onAppear {
Task {
while true {
// Custom loop continues during gesture
}
}
}
// GOOD
.animation(.linear(duration: 2), value: isAnimating)
// Automatically stops on gesture
Symptom: Animation runs even with Accessibility setting enabled Cause: Not checking @Environment(.accessibilityReduceMotion) Fix: Check environment value in animation decision
// BAD
withAnimation(.spring()) { // Always animates
state.toggle()
}
// GOOD
let animation = reduceMotion ? Animation.none : .spring()
withAnimation(animation) {
state.toggle()
}
## Gesture Animation Audit: [Component/Screen Name]
### Velocity Mapping Compliance
| Animation | Velocity Used | Duration Range | Status |
|-----------|---------------|-----------------|--------|
| Card dismiss | gesture.velocity.height | 200-800ms | ✅ |
| Spring bounce | gesture.velocity.width | 300-1000ms | ⚠️ |
### keyframeAnimator Usage
| Animation | Interruptible | Trigger | Status |
|-----------|---------------|---------|--------|
| Like heart | ✅ Auto-stops | isLiked | ✅ |
| Loading loop | ❌ Manual cancel | showLoading | ❌ |
### Spring Physics Parameters
| Animation | Response | Damping | Blending | Status |
|-----------|----------|---------|----------|--------|
| Card snap | 0.4 | 0.7 | 0.2 | ✅ |
| Button tap | 0.6 | 0.5 | 0.0 | ❌ |
### Gesture State Management
| State | Type | Reset On | Usage | Status |
|------|------|----------|-------|--------|
| isPressed | @GestureState | Gesture end | Temporary feedback | ✅ |
| selectedCard | @State | Manual | Persistent selection | ✅ |
### Haptic Synchronization
| Milestone | Timing | Style | Synced | Status |
|-----------|--------|-------|--------|--------|
| Gesture start | Immediate | medium | ✅ | ✅ |
| Animation peak | 200ms | light | ⚠️ | ⚠️ |
### Accessibility Compliance
| Criterion | Status | Issues |
|-----------|--------|--------|
| Reduce Motion respected | ✅ | None |
| Touch target ≥ 44×44pt | ⚠️ | Drag area 32×32pt |
| Haptic independent | ✅ | Works with haptics off |
| Gesture alternative | ❌ | Swipe-only, no button |
### Performance Metrics
| Metric | Target | Actual | Status |
|--------|--------|--------|--------|
| Gesture response | < 100ms | 45ms | ✅ |
| Frame rate during drag | 60fps | 58-60fps | ✅ |
| GPU acceleration | Transform only | Using opacity changes | ⚠️ |
### Critical Issues
**Line 45**: keyframeAnimator animation doesn't stop on gesture
```swift
// BAD
.keyframeAnimator(...) {
// Continues during gesture
}
// GOOD
.keyframeAnimator(...) {
// User gesture automatically stops—no fix needed
}
Severity: 🔴 Critical | Confidence: 95 | Rule: GA-017
Line 32: Haptic fired on every drag update
// BAD
.onChanged { gesture in
UIImpactFeedbackGenerator(style: .light).impactOccurred() // 60x/sec!
}
// GOOD
.onEnded { gesture in
UIImpactFeedbackGenerator(style: .light).impactOccurred() // Once
}
Severity: 🔴 Critical | Confidence: 90 | Rule: GA-070
Line 18: Animation not interruptible by gesture
// BAD
Task {
while animating {
scale = scale + 0.01 // Custom loop, won't stop
}
}
// GOOD
.animation(.linear(duration: 1), value: triggerValue)
// Stops automatically on gesture
Severity: 🟠 Important | Confidence: 85 | Rule: GA-008
Line 72: Reduce Motion not checked
// BAD
withAnimation(.spring()) {
state.toggle() // Always animates
}
// GOOD
let animation = reduceMotion ? Animation.none : .spring()
withAnimation(animation) {
state.toggle()
}
Severity: 🟡 Warning | Confidence: 80 | Rule: GA-075
Line 41: Consider velocity-mapped duration for faster response
// Current
withAnimation(.easeOut(duration: 0.5)) { ... }
// Suggested
let duration = max(0.2, min(1.0, distance / abs(velocity)))
withAnimation(.easeOut(duration: duration)) { ... }
Severity: 🔵 Suggestion | Confidence: 75 | Rule: GA-002
## Summary
This audit ensures gesture-driven animations follow iOS 18+ best practices including:
- Physics-based velocity mapping
- Proper use of keyframeAnimator and PhaseAnimator
- Interruptible animation patterns
- Haptic feedback synchronization
- Accessibility compliance (reduce motion, touch targets)
- High performance (60fps+ during gestures)
Use the checklist items (GA-001 to GA-096) to systematically verify gesture animation quality.