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:
// ✅ 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:
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+):
// 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:
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:
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:
// ✅ 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
-
Engine (CHHapticEngine ) - Link to the phone's actuator
-
Player (CHHapticPatternPlayer ) - Playback control
-
Pattern (CHHapticPattern ) - Collection of events over time
-
Events (CHHapticEvent ) - Building blocks specifying the experience
CHHapticEngine Lifecycle
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).
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).
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
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:
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
{ "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
{ "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
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)
-
Create visual animation (e.g., shield transformation, 500ms)
-
Design audio (convey energy gain and robustness)
-
Design haptic (feel the transformation)
-
Test harmony - Do all three senses work together?
-
Iterate - Swap AHAP assets until coherent
-
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
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
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
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
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
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
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
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:
-
Device doesn't support Core Haptics (< iPhone 8)
-
Haptics disabled in Settings
-
Low Power Mode enabled
Solution:
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:
-
Check Settings → Sounds & Haptics → System Haptics is ON
-
Check Low Power Mode is OFF
-
Verify device is iPhone 8 or newer
-
Check intensity > 0.3 (values below may be too subtle)
-
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:
-
Not calling prepare() before haptic
-
Audio/haptic started at different times
-
Heavy main thread work blocking playback
Solution:
// ✅ 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