home / skills / charleswiltgen / axiom / axiom-haptics

This skill helps you implement and debug haptic feedback across UIFeedbackGenerator and Core Haptics for synchronized audio-haptics.

npx playbooks add skill charleswiltgen/axiom --skill axiom-haptics

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
20.8 KB
---
name: axiom-haptics
description: Use when implementing haptic feedback, Core Haptics patterns, audio-haptic synchronization, or debugging haptic issues - covers UIFeedbackGenerator, CHHapticEngine, AHAP patterns, and Apple's Causality-Harmony-Utility design principles from WWDC 2021
license: MIT
metadata:
  version: "1.0.0"
---

# Haptics & Audio Feedback

Comprehensive guide to implementing haptic feedback on iOS. Every Apple Design Award winner uses excellent haptic feedback - Camera, Maps, Weather all use haptics masterfully to create delightful, responsive experiences.

## Overview

Haptic feedback provides tactile confirmation of user actions and system events. When designed thoughtfully using the Causality-Harmony-Utility framework, axiom-haptics transform interfaces from functional to delightful.

This skill covers both simple haptics (`UIFeedbackGenerator`) and advanced custom patterns (`Core Haptics`), with real-world examples and audio-haptic synchronization techniques.

## When to Use This Skill

- Adding haptic feedback to user interactions
- Choosing between UIFeedbackGenerator and Core Haptics
- Designing audio-haptic experiences that feel unified
- Creating custom haptic patterns with AHAP files
- Synchronizing haptics with animations and audio
- Debugging haptic issues (simulator vs device)
- Optimizing haptic performance and battery impact

## System Requirements

- **iOS 10+** for UIFeedbackGenerator
- **iOS 13+** for Core Haptics (CHHapticEngine)
- **iPhone 8+** for Core Haptics hardware support
- **Physical device required** - haptics cannot be felt in Simulator

---

## Part 1: Design Principles (WWDC 2021/10278)

Apple's audio and haptic design teams established three core principles for multimodal feedback:

### Causality - Make it obvious what caused the feedback

**Problem**: User can't tell what triggered the haptic
**Solution**: Haptic timing must match the visual/interaction moment

**Example from WWDC**:
- ✅ Ball hits wall → haptic fires at collision moment
- ❌ Ball hits wall → haptic fires 100ms later (confusing)

**Code pattern**:
```swift
// ✅ Immediate feedback on touch
@objc func buttonTapped() {
    let generator = UIImpactFeedbackGenerator(style: .medium)
    generator.impactOccurred()  // Fire immediately
    performAction()
}

// ❌ Delayed feedback loses causality
@objc func buttonTapped() {
    performAction()
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
        let generator = UIImpactFeedbackGenerator(style: .medium)
        generator.impactOccurred()  // Too late!
    }
}
```

### Harmony - Senses work best when coherent

**Problem**: Visual, audio, and haptic don't match
**Solution**: All three senses should feel like a unified experience

**Example from WWDC**:
- Small ball → light haptic + high-pitched sound
- Large ball → heavy haptic + low-pitched sound
- Shield transformation → continuous haptic + progressive audio

**Key insight**: A large object should **feel** heavy, **sound** low and resonant, and **look** substantial. All three senses reinforce the same experience.

### Utility - Provide clear value

**Problem**: Haptics used everywhere "just because we can"
**Solution**: Reserve haptics for significant moments that benefit the user

**When to use haptics**:
- ✅ Confirming an important action (payment completed)
- ✅ Alerting to critical events (low battery)
- ✅ Providing continuous feedback (scrubbing slider)
- ✅ Enhancing delight (app launch flourish)

**When NOT to use haptics**:
- ❌ Every single tap (overwhelming)
- ❌ Scrolling through long lists (battery drain)
- ❌ Background events user can't see (confusing)
- ❌ Decorative animations (no value)

---

## Part 2: UIFeedbackGenerator (Simple Haptics)

For most apps, `UIFeedbackGenerator` provides 3 simple haptic types without custom patterns.

### UIImpactFeedbackGenerator

Physical collision or impact sensation.

**Styles** (ordered light → heavy):
- `.light` - Small, delicate tap
- `.medium` - Standard tap (most common)
- `.heavy` - Strong, solid impact
- `.rigid` - Firm, precise tap
- `.soft` - Gentle, cushioned tap

**Usage pattern**:
```swift
class MyViewController: UIViewController {
    let impactGenerator = UIImpactFeedbackGenerator(style: .medium)

    override func viewDidLoad() {
        super.viewDidLoad()
        // Prepare reduces latency for next impact
        impactGenerator.prepare()
    }

    @objc func userDidTap() {
        impactGenerator.impactOccurred()
    }
}
```

**Intensity variation** (iOS 13+):
```swift
// intensity: 0.0 (lightest) to 1.0 (strongest)
impactGenerator.impactOccurred(intensity: 0.5)
```

**Common use cases**:
- Button taps (`.medium`)
- Toggle switches (`.light`)
- Deleting items (`.heavy`)
- Confirming selections (`.rigid`)

### UISelectionFeedbackGenerator

Discrete selection changes (picker wheels, segmented controls).

**Usage**:
```swift
class PickerViewController: UIViewController {
    let selectionGenerator = UISelectionFeedbackGenerator()

    func pickerView(_ picker: UIPickerView, didSelectRow row: Int,
                    inComponent component: Int) {
        selectionGenerator.selectionChanged()
    }
}
```

**Feels like**: Clicking a physical wheel with detents

**Common use cases**:
- Picker wheels
- Segmented controls
- Page indicators
- Step-through interfaces

### UINotificationFeedbackGenerator

System-level success/warning/error feedback.

**Types**:
- `.success` - Task completed successfully
- `.warning` - Attention needed, but not critical
- `.error` - Critical error occurred

**Usage**:
```swift
let notificationGenerator = UINotificationFeedbackGenerator()

func submitForm() {
    // Validate form
    if isValid {
        notificationGenerator.notificationOccurred(.success)
        saveData()
    } else {
        notificationGenerator.notificationOccurred(.error)
        showValidationErrors()
    }
}
```

**Best practice**: Match haptic type to user outcome
- ✅ Payment succeeds → `.success`
- ✅ Form validation fails → `.error`
- ✅ Approaching storage limit → `.warning`

### Performance: prepare()

Call `prepare()` before the haptic to reduce latency:

```swift
// ✅ Good - prepare before user action
@IBAction func buttonTouchDown(_ sender: UIButton) {
    impactGenerator.prepare()  // User's finger is down
}

@IBAction func buttonTouchUpInside(_ sender: UIButton) {
    impactGenerator.impactOccurred()  // Immediate haptic
}

// ❌ Bad - unprepared haptic may lag
@IBAction func buttonTapped(_ sender: UIButton) {
    let generator = UIImpactFeedbackGenerator()
    generator.impactOccurred()  // May have 10-20ms delay
}
```

**Prepare timing**: System keeps engine ready for ~1 second after `prepare()`.

---

## Part 3: Core Haptics (Custom Haptics)

For apps needing custom patterns, `Core Haptics` provides full control over haptic waveforms.

### Four Fundamental Elements

1. **Engine** (`CHHapticEngine`) - Link to the phone's actuator
2. **Player** (`CHHapticPatternPlayer`) - Playback control
3. **Pattern** (`CHHapticPattern`) - Collection of events over time
4. **Events** (`CHHapticEvent`) - Building blocks specifying the experience

### CHHapticEngine Lifecycle

```swift
import CoreHaptics

class HapticManager {
    var engine: CHHapticEngine?

    func initializeHaptics() {
        // Check device support
        guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else {
            print("Device doesn't support haptics")
            return
        }

        do {
            // Create engine
            engine = try CHHapticEngine()

            // Handle interruptions (calls, Siri, etc.)
            engine?.stoppedHandler = { reason in
                print("Engine stopped: \(reason)")
                self.restartEngine()
            }

            // Handle reset (audio session changes)
            engine?.resetHandler = {
                print("Engine reset")
                self.restartEngine()
            }

            // Start engine
            try engine?.start()

        } catch {
            print("Failed to create haptic engine: \(error)")
        }
    }

    func restartEngine() {
        do {
            try engine?.start()
        } catch {
            print("Failed to restart engine: \(error)")
        }
    }
}
```

**Critical**: Always set `stoppedHandler` and `resetHandler` to handle system interruptions.

### CHHapticEvent Types

#### Transient Events

Short, discrete feedback (like a tap).

```swift
let intensity = CHHapticEventParameter(
    parameterID: .hapticIntensity,
    value: 1.0  // 0.0 to 1.0
)

let sharpness = CHHapticEventParameter(
    parameterID: .hapticSharpness,
    value: 0.5  // 0.0 (dull) to 1.0 (sharp)
)

let event = CHHapticEvent(
    eventType: .hapticTransient,
    parameters: [intensity, sharpness],
    relativeTime: 0.0  // Seconds from pattern start
)
```

**Parameters**:
- `hapticIntensity`: Strength (0.0 = barely felt, 1.0 = maximum)
- `hapticSharpness`: Character (0.0 = dull thud, 1.0 = crisp snap)

#### Continuous Events

Sustained feedback over time (like a vibration motor).

```swift
let intensity = CHHapticEventParameter(
    parameterID: .hapticIntensity,
    value: 0.8
)

let sharpness = CHHapticEventParameter(
    parameterID: .hapticSharpness,
    value: 0.3
)

let event = CHHapticEvent(
    eventType: .hapticContinuous,
    parameters: [intensity, sharpness],
    relativeTime: 0.0,
    duration: 2.0  // Seconds
)
```

**Use cases**:
- Rolling texture as object moves
- Motor running
- Charging progress
- Long press feedback

### Creating and Playing Patterns

```swift
func playCustomPattern() {
    // Create events
    let tap1 = CHHapticEvent(
        eventType: .hapticTransient,
        parameters: [
            CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.5),
            CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.5)
        ],
        relativeTime: 0.0
    )

    let tap2 = CHHapticEvent(
        eventType: .hapticTransient,
        parameters: [
            CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.7),
            CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.7)
        ],
        relativeTime: 0.3
    )

    let tap3 = CHHapticEvent(
        eventType: .hapticTransient,
        parameters: [
            CHHapticEventParameter(parameterID: .hapticIntensity, value: 1.0),
            CHHapticEventParameter(parameterID: .hapticSharpness, value: 1.0)
        ],
        relativeTime: 0.6
    )

    do {
        // Create pattern from events
        let pattern = try CHHapticPattern(
            events: [tap1, tap2, tap3],
            parameters: []
        )

        // Create player
        let player = try engine?.makePlayer(with: pattern)

        // Play
        try player?.start(atTime: CHHapticTimeImmediate)

    } catch {
        print("Failed to play pattern: \(error)")
    }
}
```

### CHHapticAdvancedPatternPlayer - Looping

For continuous feedback (rolling textures, motors), use advanced player:

```swift
func startRollingTexture() {
    let event = CHHapticEvent(
        eventType: .hapticContinuous,
        parameters: [
            CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.4),
            CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.2)
        ],
        relativeTime: 0.0,
        duration: 0.5
    )

    do {
        let pattern = try CHHapticPattern(events: [event], parameters: [])

        // Use advanced player for looping
        let player = try engine?.makeAdvancedPlayer(with: pattern)

        // Enable looping
        try player?.loopEnabled = true

        // Start
        try player?.start(atTime: CHHapticTimeImmediate)

        // Update intensity dynamically based on ball speed
        updateTextureIntensity(player: player)

    } catch {
        print("Failed to start texture: \(error)")
    }
}

func updateTextureIntensity(player: CHHapticAdvancedPatternPlayer?) {
    let newIntensity = calculateIntensityFromBallSpeed()

    let intensityParam = CHHapticDynamicParameter(
        parameterID: .hapticIntensityControl,
        value: newIntensity,
        relativeTime: 0
    )

    try? player?.sendParameters([intensityParam], atTime: CHHapticTimeImmediate)
}
```

**Key difference**: `CHHapticPatternPlayer` plays once, `CHHapticAdvancedPatternPlayer` supports looping and dynamic parameter updates.

---

## Part 4: AHAP Files (Apple Haptic Audio Pattern)

AHAP (Apple Haptic Audio Pattern) files are JSON files combining haptic events and audio.

### Basic AHAP Structure

```json
{
  "Version": 1.0,
  "Metadata": {
    "Project": "My App",
    "Created": "2024-01-15"
  },
  "Pattern": [
    {
      "Event": {
        "Time": 0.0,
        "EventType": "HapticTransient",
        "EventParameters": [
          {
            "ParameterID": "HapticIntensity",
            "ParameterValue": 1.0
          },
          {
            "ParameterID": "HapticSharpness",
            "ParameterValue": 0.5
          }
        ]
      }
    }
  ]
}
```

### Adding Audio to AHAP

```json
{
  "Version": 1.0,
  "Pattern": [
    {
      "Event": {
        "Time": 0.0,
        "EventType": "AudioCustom",
        "EventParameters": [
          {
            "ParameterID": "AudioVolume",
            "ParameterValue": 0.8
          }
        ],
        "EventWaveformPath": "ShieldA.wav"
      }
    },
    {
      "Event": {
        "Time": 0.0,
        "EventType": "HapticContinuous",
        "EventDuration": 0.5,
        "EventParameters": [
          {
            "ParameterID": "HapticIntensity",
            "ParameterValue": 0.6
          }
        ]
      }
    }
  ]
}
```

### Loading AHAP Files

```swift
func loadAHAPPattern(named name: String) -> CHHapticPattern? {
    guard let url = Bundle.main.url(forResource: name, withExtension: "ahap") else {
        print("AHAP file not found")
        return nil
    }

    do {
        return try CHHapticPattern(contentsOf: url)
    } catch {
        print("Failed to load AHAP: \(error)")
        return nil
    }
}

// Usage
if let pattern = loadAHAPPattern(named: "ShieldTransient") {
    let player = try? engine?.makePlayer(with: pattern)
    try? player?.start(atTime: CHHapticTimeImmediate)
}
```

### Design Workflow (WWDC Example)

1. **Create visual animation** (e.g., shield transformation, 500ms)
2. **Design audio** (convey energy gain and robustness)
3. **Design haptic** (feel the transformation)
4. **Test harmony** - Do all three senses work together?
5. **Iterate** - Swap AHAP assets until coherent
6. **Implement** - Update code to use final assets

**Example iteration**: Shield initially used 3 transient pulses (haptic) + progressive continuous sound (audio) → no harmony. Solution: Switch to continuous haptic + ShieldA.wav audio → unified experience.

---

## Part 5: Audio-Haptic Synchronization

### Matching Animation Timing

```swift
class ViewController: UIViewController {
    let animationDuration: TimeInterval = 0.5

    func performShieldTransformation() {
        // Start haptic/audio simultaneously with animation
        playShieldPattern()

        UIView.animate(withDuration: animationDuration) {
            self.shieldView.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
            self.shieldView.alpha = 0.8
        }
    }

    func playShieldPattern() {
        if let pattern = loadAHAPPattern(named: "ShieldContinuous") {
            let player = try? engine?.makePlayer(with: pattern)
            try? player?.start(atTime: CHHapticTimeImmediate)
        }
    }
}
```

**Critical**: Fire haptic at the exact moment the visual change occurs, not before or after.

### Coordinating with Audio

```swift
import AVFoundation

class AudioHapticCoordinator {
    let audioPlayer: AVAudioPlayer
    let hapticEngine: CHHapticEngine

    func playCoordinatedExperience() {
        // Prepare both systems
        hapticEngine.notifyWhenPlayersFinished { _ in
            return .stopEngine
        }

        // Start at exact same moment
        let startTime = CACurrentMediaTime() + 0.05  // Small delay for sync

        // Start audio
        audioPlayer.play(atTime: startTime)

        // Start haptic
        if let pattern = loadAHAPPattern(named: "CoordinatedPattern") {
            let player = try? hapticEngine.makePlayer(with: pattern)
            try? player?.start(atTime: CHHapticTimeImmediate)
        }
    }
}
```

---

## Part 6: Common Patterns

### Button Tap

```swift
class HapticButton: UIButton {
    let impactGenerator = UIImpactFeedbackGenerator(style: .medium)

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesBegan(touches, with: event)
        impactGenerator.prepare()
    }

    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesEnded(touches, with: event)
        impactGenerator.impactOccurred()
    }
}
```

### Slider Scrubbing

```swift
class HapticSlider: UISlider {
    let selectionGenerator = UISelectionFeedbackGenerator()
    var lastValue: Float = 0

    @objc func valueChanged() {
        let threshold: Float = 0.1

        if abs(value - lastValue) >= threshold {
            selectionGenerator.selectionChanged()
            lastValue = value
        }
    }
}
```

### Pull-to-Refresh

```swift
class PullToRefreshController: UIViewController {
    let impactGenerator = UIImpactFeedbackGenerator(style: .medium)
    var isRefreshing = false

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        let threshold: CGFloat = -100
        let offset = scrollView.contentOffset.y

        if offset <= threshold && !isRefreshing {
            impactGenerator.impactOccurred()
            isRefreshing = true
            beginRefresh()
        }
    }
}
```

### Success/Error Feedback

```swift
func handleServerResponse(_ result: Result<Data, Error>) {
    let notificationGenerator = UINotificationFeedbackGenerator()

    switch result {
    case .success:
        notificationGenerator.notificationOccurred(.success)
        showSuccessMessage()
    case .failure:
        notificationGenerator.notificationOccurred(.error)
        showErrorAlert()
    }
}
```

---

## Part 7: Testing & Debugging

### Simulator Limitations

**Haptics DO NOT work in Simulator**. You will see:
- No haptic feedback
- No warnings or errors
- Code runs normally

**Solution**: Always test on physical device (iPhone 8 or newer).

### Device Testing Checklist

- [ ] Test with Haptics disabled in Settings → Sounds & Haptics
- [ ] Test with Low Power Mode enabled
- [ ] Test during incoming call (engine may stop)
- [ ] Test with audio playing in background
- [ ] Test with different intensity/sharpness values
- [ ] Verify battery impact (Instruments Energy Log)

### Debug Logging

```swift
func playHaptic() {
    #if DEBUG
    print("🔔 Playing haptic - Engine running: \(engine?.currentTime ?? -1)")
    #endif

    do {
        let player = try engine?.makePlayer(with: pattern)
        try player?.start(atTime: CHHapticTimeImmediate)

        #if DEBUG
        print("✅ Haptic started successfully")
        #endif
    } catch {
        #if DEBUG
        print("❌ Haptic failed: \(error.localizedDescription)")
        #endif
    }
}
```

---

## Troubleshooting

### Engine fails to start

**Symptom**: `CHHapticEngine.start()` throws error

**Causes**:
1. Device doesn't support Core Haptics (< iPhone 8)
2. Haptics disabled in Settings
3. Low Power Mode enabled

**Solution**:
```swift
func safelyStartEngine() {
    guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else {
        print("Device doesn't support haptics")
        return
    }

    do {
        try engine?.start()
    } catch {
        print("Engine start failed: \(error)")
        // Fall back to UIFeedbackGenerator
        useFallbackHaptics()
    }
}
```

### Haptics not felt

**Symptom**: Code runs but no haptic felt on device

**Debug steps**:
1. Check Settings → Sounds & Haptics → System Haptics is ON
2. Check Low Power Mode is OFF
3. Verify device is iPhone 8 or newer
4. Check intensity > 0.3 (values below may be too subtle)
5. Test with UIFeedbackGenerator to isolate Core Haptics vs system issue

### Audio out of sync with haptics

**Symptom**: Audio plays but haptic delayed or vice versa

**Causes**:
1. Not calling `prepare()` before haptic
2. Audio/haptic started at different times
3. Heavy main thread work blocking playback

**Solution**:
```swift
// ✅ Synchronized start
func playCoordinated() {
    impactGenerator.prepare()  // Reduce latency

    // Start both simultaneously
    audioPlayer.play()
    impactGenerator.impactOccurred()
}
```

### Audio file errors with AHAP

**Symptom**: AHAP pattern fails to load or play

**Cause**: Audio file > 4.2 MB or > 23 seconds

**Solution**: Keep audio files small and short. Use compressed formats (AAC) and trim to essential duration.

---

## Resources

**WWDC**: 2021-10278, 2019-520, 2019-223

**Docs**: /corehaptics, /corehaptics/chhapticengine

**Skills**: axiom-swiftui-animation-ref, axiom-ui-testing, axiom-accessibility-diag

Overview

This skill helps implement and debug haptic feedback across modern xOS platforms, covering UIFeedbackGenerator, Core Haptics (CHHapticEngine), AHAP patterns, and Apple’s Causality–Harmony–Utility design principles. It focuses on practical patterns, audio-haptic synchronization, lifecycle handling, and performance tips to make tactile experiences feel immediate and meaningful.

How this skill works

It inspects where to use simple system generators versus custom Core Haptics patterns, how to build CHHapticPattern/CHHapticEvent sequences or AHAP JSON, and how to drive CHHapticAdvancedPatternPlayer for looping and dynamic updates. The skill also shows lifecycle management (start/restart/handlers), hardware capability checks, and debugging practices for simulator vs device testing.

When to use it

  • Add tactile confirmation to important user actions (payments, form submits, deletes).
  • Decide between UIFeedbackGenerator for simple taps and Core Haptics for custom waveforms or synchronized audio.
  • Create AHAP files when you need tight audio+haptic timing and reusable patterns.
  • Implement continuous or looped feedback (rolling textures, motor simulations) with dynamic intensity.
  • Debug haptic issues, latency, or interruptions and test on physical devices.

Best practices

  • Follow Causality: fire haptics exactly when the user perceives the triggering event to avoid confusion.
  • Favor Harmony: align visual, audio, and haptic traits (size → weight, pitch → perceived mass).
  • Use Utility: reserve haptics for moments that add clear value; avoid gratuitous use to save battery.
  • Call prepare() on UIFeedbackGenerator before the expected interaction window to reduce latency.
  • Check CHHapticEngine.capabilitiesForHardware() and set stoppedHandler/resetHandler to restart safely.
  • Test patterns on physical devices (Core Haptics requires supported hardware) and measure battery/CPU impact.

Example use cases

  • Confirming a payment with UINotificationFeedbackGenerator.success and a short AHAP flourish.
  • Simulating a rolling texture under a draggable object using CHHapticAdvancedPatternPlayer and dynamic intensity updates.
  • Creating a shield transformation that combines a continuous haptic swell with synchronized audio using an AHAP file.
  • Providing discrete selection detents in a custom picker with UISelectionFeedbackGenerator.
  • Building an impact cascade (light→medium→heavy) with a CHHapticPattern for tactile storytelling.

FAQ

Can I feel haptics in the Simulator?

No. Haptics must be tested on a physical device; the Simulator cannot reproduce tactile feedback.

How do I know if a device supports Core Haptics?

Call CHHapticEngine.capabilitiesForHardware().supportsHaptics; Core Haptics requires modern hardware (iPhone 8+ and iOS 13+ for full support).