# haptics.md — full library iPhone haptic feedback patterns with SwiftUI, UIKit, and Core Haptics code. Conventions: UIFeedbackGenerator needs no capability check; Core Haptics requires CHHapticEngine.capabilitiesForHardware().supportsHaptics. Call prepare() shortly before firing a generator. Fire haptics at the exact moment of the visual change. # Foundations ## Reusable HapticManager - Use case: One shared object that prepares generators once and exposes every standard haptic as a single call. - Feel: N/A — infrastructure. Guarantees minimal latency by keeping the Taptic Engine prepared. - APIs: UIImpactFeedbackGenerator, UISelectionFeedbackGenerator, UINotificationFeedbackGenerator - Minimum OS: iOS 13+ ### Swift (UIKit + SwiftUI compatible) ```swift import UIKit /// Central access point for standard haptics. /// Call `HapticManager.shared.prepare()` shortly before an interaction /// (e.g. when a screen appears) so the Taptic Engine is spun up. final class HapticManager { static let shared = HapticManager() private let lightImpact = UIImpactFeedbackGenerator(style: .light) private let mediumImpact = UIImpactFeedbackGenerator(style: .medium) private let heavyImpact = UIImpactFeedbackGenerator(style: .heavy) private let softImpact = UIImpactFeedbackGenerator(style: .soft) private let rigidImpact = UIImpactFeedbackGenerator(style: .rigid) private let selection = UISelectionFeedbackGenerator() private let notification = UINotificationFeedbackGenerator() private init() {} /// Wakes the Taptic Engine to remove first-hit latency. func prepare() { lightImpact.prepare() selection.prepare() notification.prepare() } // MARK: Impacts (physical collisions: taps, snaps, drops) func tapLight(intensity: CGFloat = 1.0) { lightImpact.impactOccurred(intensity: intensity) } func tapMedium(intensity: CGFloat = 1.0) { mediumImpact.impactOccurred(intensity: intensity) } func tapHeavy(intensity: CGFloat = 1.0) { heavyImpact.impactOccurred(intensity: intensity) } func tapSoft() { softImpact.impactOccurred() } // rounded, muffled func tapRigid() { rigidImpact.impactOccurred() } // sharp, precise // MARK: Selection (ticks while values change) func selectionChanged() { selection.selectionChanged() } // MARK: Notifications (outcomes) func success() { notification.notificationOccurred(.success) } func warning() { notification.notificationOccurred(.warning) } func error() { notification.notificationOccurred(.error) } } // Usage anywhere in the app: // HapticManager.shared.tapLight() // HapticManager.shared.success() ``` ### Notes - Generators are cheap to keep alive; keeping one instance per style avoids re-allocations. - prepare() keeps the engine ready for ~1–2 seconds. Call it on touch-down or on screen appear, then fire on touch-up. - Haptics are silently ignored on devices without a Taptic Engine (iPads, old iPhones) — no capability check needed for UIFeedbackGenerator. --- ## Core Haptics engine setup - Use case: Boilerplate for custom haptic patterns: capability check, engine creation, and auto-restart handlers. - Feel: N/A — infrastructure required by every custom pattern in this library. - APIs: CHHapticEngine, CHHapticEvent, CHHapticPattern - Minimum OS: iOS 13+ ### Core Haptics ```swift import CoreHaptics /// Owns a CHHapticEngine and keeps it alive across app lifecycle events. final class HapticEngine { static let shared = HapticEngine() private var engine: CHHapticEngine? /// True on iPhone 8 and later. Always gate custom patterns on this. var supportsHaptics: Bool { CHHapticEngine.capabilitiesForHardware().supportsHaptics } private init() { guard supportsHaptics else { return } do { engine = try CHHapticEngine() // Restart automatically if the system stops the engine // (audio session interruption, backgrounding, etc.) engine?.resetHandler = { [weak self] in try? self?.engine?.start() } engine?.stoppedHandler = { reason in print("Haptic engine stopped: \(reason)") } try engine?.start() } catch { print("Haptic engine failed to start: \(error)") } } /// Plays an array of events as a one-shot pattern. func play(_ events: [CHHapticEvent]) { guard supportsHaptics, let engine else { return } do { let pattern = try CHHapticPattern(events: events, parameters: []) let player = try engine.makePlayer(with: pattern) try engine.start() // no-op if already running try player.start(atTime: CHHapticTimeImmediate) } catch { print("Failed to play pattern: \(error)") } } } ``` ### Notes - Two building blocks: .hapticTransient (a tick — like a drum hit) and .hapticContinuous (a sustained buzz with a duration). - Two parameters shape every event: hapticIntensity (strength, 0–1) and hapticSharpness (0 = round/dull thud, 1 = crisp/precise click). - Restart the engine in sceneDidBecomeActive if you play haptics right after foregrounding. --- ## SwiftUI .sensoryFeedback cheat sheet - Use case: Every built-in SwiftUI feedback value (iOS 17+) in one view, wired to trigger values. - Feel: Covers the full built-in vocabulary: outcomes, ticks, impacts, and state changes. - APIs: .sensoryFeedback(_:trigger:), SensoryFeedback - Minimum OS: iOS 17+ ### SwiftUI (iOS 17+) ```swift import SwiftUI struct SensoryFeedbackCheatSheet: View { @State private var trigger = 0 var body: some View { Button("Fire haptic") { trigger += 1 } // --- Outcomes ------------------------------------------------- .sensoryFeedback(.success, trigger: trigger) // task finished // .sensoryFeedback(.warning, trigger: trigger) // .sensoryFeedback(.error, trigger: trigger) // --- Ticks & value changes ------------------------------------ // .sensoryFeedback(.selection, trigger: trigger) // picker tick // .sensoryFeedback(.increase, trigger: trigger) // stepper + // .sensoryFeedback(.decrease, trigger: trigger) // stepper − // --- Physical impacts ----------------------------------------- // .sensoryFeedback(.impact, trigger: trigger) // default collision // .sensoryFeedback(.impact(weight: .light, intensity: 0.7), trigger: trigger) // .sensoryFeedback(.impact(weight: .medium, intensity: 1.0), trigger: trigger) // .sensoryFeedback(.impact(weight: .heavy, intensity: 1.0), trigger: trigger) // .sensoryFeedback(.impact(flexibility: .soft, intensity: 0.8), trigger: trigger) // .sensoryFeedback(.impact(flexibility: .solid, intensity: 0.8), trigger: trigger) // .sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.8), trigger: trigger) // --- Activities & alignment ----------------------------------- // .sensoryFeedback(.start, trigger: isRecording) // activity began // .sensoryFeedback(.stop, trigger: isRecording) // activity ended // .sensoryFeedback(.alignment, trigger: snapped) // canvas guide snap // .sensoryFeedback(.levelChange, trigger: zoomLevel) // .sensoryFeedback(.pathComplete, trigger: done) // iOS 18+ } } // Conditional variant — decide at fire time: struct ConditionalFeedback: View { @State private var result: Result? = nil @State private var attempt = 0 var body: some View { Button("Submit") { attempt += 1 /* ... */ } .sensoryFeedback(trigger: attempt) { _, _ in switch result { case .success: return .success case .failure: return .error case .none: return nil // returning nil plays nothing } } } } ``` ### Notes - The feedback fires whenever the trigger value changes — use a counter for "fire every tap" or a Bool/state for transitions. - Use the closure form to inspect old/new values and pick a feedback (or nil) at runtime. - .selection only fires while a value is actively changing; it is the SwiftUI face of UISelectionFeedbackGenerator. --- ## UIKit feedback generator cheat sheet - Use case: All three UIFeedbackGenerator families and every style, with the prepare → fire pattern. - Feel: Impacts = collisions. Selection = ticks. Notification = three-part outcome patterns. - APIs: UIImpactFeedbackGenerator, UISelectionFeedbackGenerator, UINotificationFeedbackGenerator - Minimum OS: iOS 10+ (intensity: iOS 13+) ### UIKit ```swift import UIKit // --------------------------------------------------------------- // 1. IMPACT — a physical collision or snap // Styles: .light .medium .heavy (mass of the object) // .soft .rigid (stiffness of the object) // --------------------------------------------------------------- let impact = UIImpactFeedbackGenerator(style: .medium) impact.prepare() // on touch-down / screen appear impact.impactOccurred() // full strength impact.impactOccurred(intensity: 0.6) // scaled, 0.0–1.0 (iOS 13+) // --------------------------------------------------------------- // 2. SELECTION — ticks while the user moves through values // (picker rows, segmented controls, dial detents) // --------------------------------------------------------------- let selection = UISelectionFeedbackGenerator() selection.prepare() selection.selectionChanged() // call once per value change // --------------------------------------------------------------- // 3. NOTIFICATION — the outcome of a task // --------------------------------------------------------------- let note = UINotificationFeedbackGenerator() note.prepare() note.notificationOccurred(.success) // two quick taps, rising note.notificationOccurred(.warning) // two taps, falling note.notificationOccurred(.error) // three sharp buzzes // iOS 17.5+: attach the generator to a view so the system can // route feedback correctly on devices with multiple engines. let attached = UIImpactFeedbackGenerator(style: .light, view: someView) ``` ### Notes - Rule of thumb: light/soft for small UI elements, medium for standard controls, heavy/rigid for significant or destructive events. - Never fire notification haptics for passive events the user did not cause — reserve them for outcomes of user-initiated tasks. - Fire the haptic at the exact moment the visual change happens; even 50 ms of drift feels broken. --- # Taps & buttons ## Tapping a heart / like button - Use case: User taps a heart to like a post; the icon pops and a soft tap confirms it. - Feel: A single soft, rounded tap — warm rather than clicky. Slightly stronger when liking than unliking. - APIs: .sensoryFeedback(.impact(flexibility: .soft)), UIImpactFeedbackGenerator(style: .soft) - Minimum OS: iOS 17+ / iOS 13+ ### SwiftUI (iOS 17+) ```swift import SwiftUI struct LikeButton: View { @State private var isLiked = false var body: some View { Button { withAnimation(.spring(response: 0.3, dampingFraction: 0.5)) { isLiked.toggle() } } label: { Image(systemName: isLiked ? "heart.fill" : "heart") .font(.title) .foregroundStyle(isLiked ? .red : .secondary) .scaleEffect(isLiked ? 1.15 : 1.0) } // Soft tap when liking, lighter when unliking .sensoryFeedback(trigger: isLiked) { _, liked in .impact(flexibility: .soft, intensity: liked ? 0.8 : 0.5) } } } ``` ### UIKit ```swift import UIKit final class LikeButton: UIButton { private let haptic = UIImpactFeedbackGenerator(style: .soft) private(set) var isLiked = false override init(frame: CGRect) { super.init(frame: frame) setImage(UIImage(systemName: "heart"), for: .normal) addTarget(self, action: #selector(touchDown), for: .touchDown) addTarget(self, action: #selector(toggleLike), for: .touchUpInside) } required init?(coder: NSCoder) { fatalError() } @objc private func touchDown() { haptic.prepare() // wake the engine before the finger lifts } @objc private func toggleLike() { isLiked.toggle() haptic.impactOccurred(intensity: isLiked ? 0.8 : 0.5) setImage(UIImage(systemName: isLiked ? "heart.fill" : "heart"), for: .normal) tintColor = isLiked ? .systemRed : .secondaryLabel } } ``` ### Notes - Asymmetric intensity (stronger like, weaker unlike) mirrors the emotional weight of the action. - Sync the haptic with the peak of the scale animation, not the touch-down. --- ## Primary button press - Use case: A standard call-to-action button ("Continue", "Add to cart") confirms the press. - Feel: One clean medium tap on touch-up. Neutral and mechanical. - APIs: .sensoryFeedback(.impact(weight: .medium)), UIImpactFeedbackGenerator(style: .medium) - Minimum OS: iOS 17+ / iOS 10+ ### SwiftUI (iOS 17+) ```swift import SwiftUI struct PrimaryButton: View { let title: String let action: () -> Void @State private var pressCount = 0 var body: some View { Button { pressCount += 1 action() } label: { Text(title) .font(.headline) .frame(maxWidth: .infinity) .padding() .background(.blue, in: RoundedRectangle(cornerRadius: 14)) .foregroundStyle(.white) } .sensoryFeedback(.impact(weight: .medium), trigger: pressCount) } } ``` ### UIKit ```swift let button = UIButton(configuration: .filled()) button.setTitle("Continue", for: .normal) let haptic = UIImpactFeedbackGenerator(style: .medium) button.addAction(UIAction { _ in haptic.prepare() }, for: .touchDown) button.addAction(UIAction { _ in haptic.impactOccurred() // perform the action }, for: .touchUpInside) ``` ### Notes - Do not add haptics to every button — reserve them for primary or consequential actions so the signal keeps meaning. --- ## Custom keyboard key press - Use case: Each key on a custom keyboard, PIN pad, or calculator gives an instant tick on touch-down. - Feel: Very light, very crisp, zero latency. Fires on touch-down, not touch-up. - APIs: UIImpactFeedbackGenerator(style: .light), intensity 0.6 - Minimum OS: iOS 13+ ### SwiftUI ```swift import SwiftUI struct PinPadKey: View { let digit: String let onTap: (String) -> Void var body: some View { Text(digit) .font(.title.monospacedDigit()) .frame(width: 80, height: 80) .background(.thinMaterial, in: Circle()) // Fire on touch-DOWN for a keyboard feel: .onLongPressGesture(minimumDuration: 0, perform: {}) { pressing in if pressing { UIImpactFeedbackGenerator(style: .light) .impactOccurred(intensity: 0.6) onTap(digit) } } } } ``` ### UIKit ```swift final class KeyButton: UIButton { // One shared generator for the whole keyboard = lowest latency static let haptic: UIImpactFeedbackGenerator = { let g = UIImpactFeedbackGenerator(style: .light) g.prepare() return g }() override func touchesBegan(_ touches: Set, with event: UIEvent?) { super.touchesBegan(touches, with: event) Self.haptic.impactOccurred(intensity: 0.6) Self.haptic.prepare() // re-arm for the next key } } ``` ### Notes - Touch-down feedback makes typing feel fast; touch-up feedback makes it feel laggy. - Re-call prepare() after each hit so rapid typing never catches the engine asleep. --- ## Toggle / switch flip - Use case: A settings switch flips on or off with a mechanical click. - Feel: A crisp, rigid tick — like a physical switch. Slightly stronger on "on". - APIs: .sensoryFeedback(.impact(flexibility: .rigid)), UIImpactFeedbackGenerator(style: .rigid) - Minimum OS: iOS 17+ / iOS 13+ ### SwiftUI (iOS 17+) ```swift import SwiftUI struct HapticToggle: View { @State private var isOn = false var body: some View { Toggle("Notifications", isOn: $isOn) .sensoryFeedback(trigger: isOn) { _, on in .impact(flexibility: .rigid, intensity: on ? 0.7 : 0.5) } } } ``` ### UIKit ```swift let toggle = UISwitch() let haptic = UIImpactFeedbackGenerator(style: .rigid) toggle.addAction(UIAction { action in guard let sender = action.sender as? UISwitch else { return } haptic.impactOccurred(intensity: sender.isOn ? 0.7 : 0.5) }, for: .valueChanged) ``` ### Notes - The system UISwitch already plays its own haptic on real devices — add a custom one only to custom-drawn toggles. --- ## Tab bar / navigation selection - Use case: Switching tabs in a custom tab bar gives a subtle tick per change. - Feel: The quietest haptic available — a selection tick. Fires only when the tab actually changes. - APIs: .sensoryFeedback(.selection), UISelectionFeedbackGenerator - Minimum OS: iOS 17+ / iOS 10+ ### SwiftUI (iOS 17+) ```swift import SwiftUI struct CustomTabBar: View { @Binding var selected: Int let icons = ["house", "magnifyingglass", "bell", "person"] var body: some View { HStack { ForEach(icons.indices, id: \.self) { i in Button { selected = i } label: { Image(systemName: icons[i]) .font(.title3) .foregroundStyle(selected == i ? .primary : .tertiary) .frame(maxWidth: .infinity) } } } .padding(.vertical, 12) .background(.bar) // Fires only when `selected` changes — repeat taps on the // same tab stay silent, which is the correct behavior. .sensoryFeedback(.selection, trigger: selected) } } ``` ### UIKit ```swift final class TabBarController: UITabBarController, UITabBarControllerDelegate { private let haptic = UISelectionFeedbackGenerator() override func viewDidLoad() { super.viewDidLoad() delegate = self } func tabBarController(_ tbc: UITabBarController, didSelect viewController: UIViewController) { haptic.selectionChanged() } } ``` --- ## Checkbox / task completion check - Use case: Checking off a to-do item: a light tick on check, and nothing (or lighter) on uncheck. - Feel: A small satisfying pop when completing; unchecking is quieter to feel like an undo. - APIs: .sensoryFeedback(.impact(weight: .light)) - Minimum OS: iOS 17+ / iOS 13+ ### SwiftUI (iOS 17+) ```swift import SwiftUI struct TaskRow: View { @State private var done = false let title: String var body: some View { Button { withAnimation(.snappy) { done.toggle() } } label: { HStack { Image(systemName: done ? "checkmark.circle.fill" : "circle") .foregroundStyle(done ? .green : .secondary) Text(title) .strikethrough(done, color: .secondary) } } // Pop on completion only; unchecking stays silent .sensoryFeedback(trigger: done) { _, isDone in isDone ? .impact(weight: .light, intensity: 0.8) : nil } } } ``` ### Notes - Returning nil from the closure form is the cleanest way to express "haptic in one direction only". --- ## Destructive action (delete) - Use case: Confirming a delete: a heavy, unmistakable thud that says "this is significant". - Feel: Heavy and rigid — noticeably weightier than any other tap in the app. - APIs: .sensoryFeedback(.impact(weight: .heavy)), UIImpactFeedbackGenerator(style: .heavy) - Minimum OS: iOS 17+ / iOS 10+ ### SwiftUI (iOS 17+) ```swift import SwiftUI struct DeleteButton: View { let onDelete: () -> Void @State private var confirming = false @State private var deleted = 0 var body: some View { Button(role: .destructive) { confirming = true } label: { Label("Delete item", systemImage: "trash") } .confirmationDialog("Delete this item?", isPresented: $confirming) { Button("Delete", role: .destructive) { deleted += 1 onDelete() } } .sensoryFeedback(.impact(weight: .heavy), trigger: deleted) } } ``` ### UIKit ```swift let haptic = UIImpactFeedbackGenerator(style: .heavy) func confirmDelete() { haptic.prepare() let alert = UIAlertController(title: "Delete this item?", message: nil, preferredStyle: .actionSheet) alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { _ in haptic.impactOccurred() // perform deletion }) alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) present(alert, animated: true) } ``` ### Notes - Fire on the confirmed deletion, not on opening the confirmation dialog. - If deletion can fail, pair with .error notification feedback on failure instead. --- # Press & hold ## Long press activation (context menu feel) - Use case: Holding an element until a context menu or secondary mode activates — the system "pop". - Feel: Silence while holding, then one medium pop exactly when the press is recognized. - APIs: .sensoryFeedback(.impact), UILongPressGestureRecognizer - Minimum OS: iOS 17+ / iOS 10+ ### SwiftUI (iOS 17+) ```swift import SwiftUI struct HoldableCard: View { @State private var activated = 0 var body: some View { RoundedRectangle(cornerRadius: 16) .fill(.quaternary) .frame(height: 120) .onLongPressGesture(minimumDuration: 0.4) { activated += 1 // show your menu / enter edit mode here } // "Pop" the instant the long press is recognized .sensoryFeedback(.impact(weight: .medium), trigger: activated) } } // Note: contextMenu {} plays the system pop automatically — // only add a manual haptic for custom long-press behaviors. ``` ### UIKit ```swift let haptic = UIImpactFeedbackGenerator(style: .medium) let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleHold(_:))) longPress.minimumPressDuration = 0.4 cardView.addGestureRecognizer(longPress) @objc func handleHold(_ gr: UILongPressGestureRecognizer) { switch gr.state { case .possible: haptic.prepare() case .began: haptic.impactOccurred() // recognition moment // enter edit mode / show menu default: break } } ``` --- ## Hold-to-confirm with rising tension - Use case: A "hold 1.5 s to confirm" button: vibration ramps up while holding, success fires at completion. - Feel: A continuous buzz that grows from faint to strong, resolved by a success pattern. Releasing early cuts it instantly. - APIs: CHHapticContinuousEvent, CHHapticParameterCurve, UINotificationFeedbackGenerator - Minimum OS: iOS 13+ ### Core Haptics + SwiftUI ```swift import SwiftUI import CoreHaptics struct HoldToConfirmButton: View { let duration: TimeInterval = 1.5 let onConfirm: () -> Void @State private var engine: CHHapticEngine? @State private var player: CHHapticAdvancedPatternPlayer? @State private var progress: CGFloat = 0 @State private var holding = false var body: some View { Text("Hold to confirm") .font(.headline).foregroundStyle(.white) .frame(maxWidth: .infinity).padding() .background( GeometryReader { geo in ZStack(alignment: .leading) { Color.red.opacity(0.4) Color.red.frame(width: geo.size.width * progress) } }, in: .rect(cornerRadius: 14) ) .onLongPressGesture( minimumDuration: duration, perform: confirm, onPressingChanged: pressingChanged ) .onAppear(perform: setupEngine) } private func setupEngine() { guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return } engine = try? CHHapticEngine() try? engine?.start() } private func pressingChanged(_ pressing: Bool) { holding = pressing if pressing { startRamp() withAnimation(.linear(duration: duration)) { progress = 1 } } else { try? player?.stop(atTime: CHHapticTimeImmediate) // cancel = cut withAnimation(.easeOut(duration: 0.2)) { progress = 0 } } } private func startRamp() { guard let engine else { return } // Continuous event whose intensity climbs 0.1 → 1.0 over the hold let event = CHHapticEvent( eventType: .hapticContinuous, parameters: [ CHHapticEventParameter(parameterID: .hapticIntensity, value: 1.0), CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.4), ], relativeTime: 0, duration: duration ) let curve = CHHapticParameterCurve( parameterID: .hapticIntensityControl, controlPoints: [ .init(relativeTime: 0, value: 0.1), .init(relativeTime: duration, value: 1.0), ], relativeTime: 0 ) if let pattern = try? CHHapticPattern(events: [event], parameterCurves: [curve]) { player = try? engine.makeAdvancedPlayer(with: pattern) try? player?.start(atTime: CHHapticTimeImmediate) } } private func confirm() { UINotificationFeedbackGenerator().notificationOccurred(.success) onConfirm() } } ``` ### Notes - The rising intensity communicates progress through the finger — the user can complete the hold without watching the screen. - Cutting the haptic instantly on release is what makes cancellation feel responsive. --- ## Hold to record (start / stop) - Use case: Voice memo style: press starts recording, release stops it. Each boundary gets its own haptic. - Feel: A firm tap at start, a lighter double-tick at stop — two distinguishable bookends. - APIs: .sensoryFeedback(.start / .stop), UIImpactFeedbackGenerator - Minimum OS: iOS 17+ / iOS 13+ ### SwiftUI (iOS 17+) ```swift import SwiftUI struct RecordButton: View { @State private var isRecording = false var body: some View { Circle() .fill(isRecording ? .red : .gray) .frame(width: 72, height: 72) .scaleEffect(isRecording ? 1.2 : 1.0) .animation(.spring(duration: 0.25), value: isRecording) .onLongPressGesture(minimumDuration: 0.1, perform: {}) { pressing in isRecording = pressing // start/stop your AVAudioRecorder here } // .start and .stop are semantic activity haptics: .sensoryFeedback(trigger: isRecording) { _, recording in recording ? .start : .stop } } } ``` ### UIKit ```swift let startHaptic = UIImpactFeedbackGenerator(style: .medium) let stopHaptic = UIImpactFeedbackGenerator(style: .light) @objc func handlePress(_ gr: UILongPressGestureRecognizer) { switch gr.state { case .began: startHaptic.impactOccurred() stopHaptic.prepare() // recorder.record() case .ended, .cancelled: stopHaptic.impactOccurred(intensity: 0.6) // recorder.stop() default: break } } ``` --- # Drag, swipe & scroll ## Pull-to-refresh threshold - Use case: The moment a pull gesture crosses the "release to refresh" threshold, plus a success on completion. - Feel: A rigid click exactly at the threshold — the sensation of a mechanism engaging — then a success pattern when new content lands. - APIs: UIImpactFeedbackGenerator(style: .rigid), .sensoryFeedback(.impact) - Minimum OS: iOS 17+ / iOS 13+ ### SwiftUI (iOS 17+) ```swift import SwiftUI struct FeedView: View { @State private var items: [String] = (1...20).map { "Item \($0)" } @State private var refreshed = 0 var body: some View { List(items, id: \.self) { Text($0) } .refreshable { // The system plays the threshold haptic for .refreshable. await reload() refreshed += 1 } .sensoryFeedback(.success, trigger: refreshed) } func reload() async { try? await Task.sleep(for: .seconds(1)) items.shuffle() } } ``` ### UIKit (custom threshold) ```swift final class FeedViewController: UITableViewController { private let threshold: CGFloat = -110 private let clickHaptic = UIImpactFeedbackGenerator(style: .rigid) private var armed = false override func scrollViewDidScroll(_ scrollView: UIScrollView) { let pull = scrollView.contentOffset.y + scrollView.adjustedContentInset.top if pull < threshold && !armed { armed = true clickHaptic.impactOccurred() // mechanism engages } else if pull > threshold * 0.6 && armed && !scrollView.isDragging { armed = false // re-arm after release } } } ``` ### Notes - One click per crossing — track an "armed" flag so wobbling around the threshold does not machine-gun the engine. --- ## Drag: pick up and drop - Use case: Dragging a card or icon: a soft lift when it detaches, a rigid thud when it lands. - Feel: Lift = soft and light (the object comes free). Drop = rigid and stronger (it settles into place). - APIs: UIImpactFeedbackGenerator(.soft / .rigid), DragGesture - Minimum OS: iOS 13+ ### SwiftUI ```swift import SwiftUI struct DraggableCard: View { @State private var offset: CGSize = .zero @State private var dragging = false private let lift = UIImpactFeedbackGenerator(style: .soft) private let drop = UIImpactFeedbackGenerator(style: .rigid) var body: some View { RoundedRectangle(cornerRadius: 16) .fill(.blue.gradient) .frame(width: 140, height: 90) .scaleEffect(dragging ? 1.06 : 1.0) .shadow(radius: dragging ? 12 : 2) .offset(offset) .gesture( DragGesture() .onChanged { value in if !dragging { dragging = true lift.impactOccurred(intensity: 0.6) // pick up drop.prepare() } offset = value.translation } .onEnded { _ in dragging = false drop.impactOccurred() // land withAnimation(.spring) { offset = .zero } } ) .animation(.spring(duration: 0.2), value: dragging) } } ``` ### Notes - The soft/rigid asymmetry is what sells the physicality: coming free is gentle, landing is definite. --- ## Swipe action arming (swipe to archive) - Use case: Swiping a row until the action arms ("release to archive") — a tick when it arms and when it disarms. - Feel: A selection tick on crossing the commit point in either direction, so the finger knows the state flipped. - APIs: UISelectionFeedbackGenerator, DragGesture - Minimum OS: iOS 10+ ### SwiftUI ```swift import SwiftUI struct SwipeableRow: View { let title: String let onArchive: () -> Void @State private var offsetX: CGFloat = 0 @State private var armed = false private let commitPoint: CGFloat = -96 private let tick = UISelectionFeedbackGenerator() var body: some View { ZStack(alignment: .trailing) { Color.orange Label("Archive", systemImage: "archivebox") .foregroundStyle(.white).padding(.trailing, 24) Text(title) .frame(maxWidth: .infinity, alignment: .leading) .padding().background(.background) .offset(x: offsetX) .gesture( DragGesture() .onChanged { value in offsetX = min(0, value.translation.width) let nowArmed = offsetX < commitPoint if nowArmed != armed { armed = nowArmed tick.selectionChanged() // arm & disarm } } .onEnded { _ in if armed { onArchive() } withAnimation(.spring) { offsetX = 0; armed = false } } ) } .clipShape(RoundedRectangle(cornerRadius: 12)) .frame(height: 56) } } ``` ### Notes - Ticking on disarm as well as arm is what lets users back out confidently without looking. - System swipeActions {} in List already provide this — hand-roll only for custom rows. --- ## List reorder ticks - Use case: Dragging a row through a list: one tick every time it displaces a neighbor, plus lift and settle haptics. - Feel: Soft lift, a small tick per position change (like passing over ridges), rigid settle at the end. - APIs: UISelectionFeedbackGenerator, UIImpactFeedbackGenerator, onMove - Minimum OS: iOS 10+ ### SwiftUI (iOS 17+) ```swift import SwiftUI struct ReorderableList: View { @State private var items = ["Inbox", "Today", "Upcoming", "Someday"] @State private var moves = 0 var body: some View { List { ForEach(items, id: \.self) { Text($0) } .onMove { from, to in items.move(fromOffsets: from, toOffset: to) moves += 1 } } .environment(\.editMode, .constant(.active)) // One tick per completed move .sensoryFeedback(.selection, trigger: moves) } } ``` ### UIKit (per-displacement ticks) ```swift // UITableView drag & drop: tick each time the target row changes. final class ReorderController: UITableViewController { private let tick = UISelectionFeedbackGenerator() private let settle = UIImpactFeedbackGenerator(style: .rigid) override func tableView(_ tableView: UITableView, targetIndexPathForMoveFromRowAt source: IndexPath, toProposedIndexPath proposed: IndexPath) -> IndexPath { if source != proposed { tick.selectionChanged() // passed over a neighbor tick.prepare() } return proposed } override func tableView(_ tableView: UITableView, moveRowAt source: IndexPath, to destination: IndexPath) { // update data source ... settle.impactOccurred(intensity: 0.8) // row lands } } ``` --- ## Textured drag (velocity-driven rumble) - Use case: Dragging across a surface that should feel textured — the faster the finger moves, the stronger the rumble. - Feel: A continuous low hum whose intensity and sharpness track finger velocity in real time. Feels like friction. - APIs: CHHapticAdvancedPatternPlayer, sendParameters, DragGesture - Minimum OS: iOS 13+ ### Core Haptics + SwiftUI ```swift import SwiftUI import CoreHaptics struct TexturedSurface: View { @State private var engine: CHHapticEngine? @State private var player: CHHapticAdvancedPatternPlayer? var body: some View { RoundedRectangle(cornerRadius: 20) .fill(.gray.opacity(0.3)) .frame(height: 220) .gesture( DragGesture(minimumDistance: 0) .onChanged { value in if player == nil { startLoop() } // Map velocity (pts/s) to 0–1 let speed = min(1, abs(value.velocity.width) / 2000 + abs(value.velocity.height) / 2000) update(intensity: Float(0.2 + 0.8 * speed), sharpness: Float(0.3 * speed)) } .onEnded { _ in stopLoop() } ) .onAppear { guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return } engine = try? CHHapticEngine() try? engine?.start() } } private func startLoop() { guard let engine else { return } let event = CHHapticEvent( eventType: .hapticContinuous, parameters: [ CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.2), CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.2), ], relativeTime: 0, duration: 100) // long-lived; we stop it manually guard let pattern = try? CHHapticPattern(events: [event], parameters: []) else { return } player = try? engine.makeAdvancedPlayer(with: pattern) try? player?.start(atTime: CHHapticTimeImmediate) } private func update(intensity: Float, sharpness: Float) { try? player?.sendParameters([ CHHapticDynamicParameter(parameterID: .hapticIntensityControl, value: intensity, relativeTime: 0), CHHapticDynamicParameter(parameterID: .hapticSharpnessControl, value: sharpness, relativeTime: 0), ], atTime: CHHapticTimeImmediate) } private func stopLoop() { try? player?.stop(atTime: CHHapticTimeImmediate) player = nil } } ``` ### Notes - sendParameters on an advanced player is the only way to modulate a haptic live — rebuilding patterns per frame is too slow. - Keep base intensity low (~0.2): texture should sit under the threshold of attention until the finger accelerates. --- # Pickers, sliders & steppers ## Picker wheel ticks - Use case: A custom wheel or scrubber that ticks once per row as it spins past values. - Feel: The classic clock-picker feel: light, dry ticks in rhythm with the scroll. - APIs: UISelectionFeedbackGenerator, .sensoryFeedback(.selection) - Minimum OS: iOS 10+ ### SwiftUI (iOS 17+) ```swift import SwiftUI struct MinutePicker: View { @State private var minutes = 5 var body: some View { Picker("Minutes", selection: $minutes) { ForEach(0..<60, id: \.self) { Text("\($0) min") } } .pickerStyle(.wheel) // System wheels tick on-device already; this pattern is for // custom scrubbers bound to any changing value: .sensoryFeedback(.selection, trigger: minutes) } } ``` ### UIKit (custom scrubber) ```swift final class Scrubber: UIScrollView, UIScrollViewDelegate { private let tick = UISelectionFeedbackGenerator() private var lastIndex = 0 private let rowHeight: CGFloat = 36 func scrollViewDidScroll(_ scrollView: UIScrollView) { let index = Int(round(scrollView.contentOffset.y / rowHeight)) if index != lastIndex { lastIndex = index tick.selectionChanged() tick.prepare() // stay armed for fast flicks } } } ``` ### Notes - Selection feedback is intentionally the lightest haptic — repeated dozens of times it must never fatigue. --- ## Slider with snapping detents - Use case: A slider that snaps to marked stops (0 / 25 / 50 / 75 / 100) and clicks at each detent. - Feel: Smooth glide between stops, a rigid micro-click when snapping into one — like a physical fader with notches. - APIs: UIImpactFeedbackGenerator(style: .rigid), DragGesture - Minimum OS: iOS 13+ ### SwiftUI ```swift import SwiftUI struct DetentSlider: View { @State private var value: Double = 50 @State private var lastDetent: Double? = nil let detents: [Double] = [0, 25, 50, 75, 100] private let click = UIImpactFeedbackGenerator(style: .rigid) var body: some View { Slider(value: $value, in: 0...100) .onChange(of: value) { _, newValue in // Which detent are we within snapping range of? let near = detents.first { abs($0 - newValue) < 2.5 } if let near, near != lastDetent { lastDetent = near value = near // snap click.impactOccurred(intensity: 0.55) } else if near == nil { lastDetent = nil // left the detent } } } } ``` ### Notes - Guard with lastDetent so hovering inside the snap zone produces exactly one click, not a stream. - For continuous (non-snapping) sliders, tick only at the min and max ends instead. --- ## Stepper increase / decrease - Use case: Quantity steppers (+ / −): direction-aware feedback so up and down feel different. - Feel: .increase is a slightly rising tick, .decrease slightly falling; the ends give a firmer "limit" bump. - APIs: .sensoryFeedback(.increase / .decrease) - Minimum OS: iOS 17+ ### SwiftUI (iOS 17+) ```swift import SwiftUI struct QuantityStepper: View { @State private var quantity = 1 @State private var limitBump = 0 let range = 1...9 var body: some View { HStack(spacing: 20) { Button { decrement() } label: { Image(systemName: "minus.circle.fill") } Text("\(quantity)").font(.title2.monospacedDigit()) Button { increment() } label: { Image(systemName: "plus.circle.fill") } } .font(.title) // Direction-aware ticks: .sensoryFeedback(trigger: quantity) { old, new in new > old ? .increase : .decrease } // Firm bump when pushing against a limit: .sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.9), trigger: limitBump) } func increment() { if quantity < range.upperBound { quantity += 1 } else { limitBump += 1 } } func decrement() { if quantity > range.lowerBound { quantity -= 1 } else { limitBump += 1 } } } ``` ### Notes - The "limit bump" replaces the tick when the value cannot move — the finger learns the boundary without an alert. --- ## Rotating dial / ratchet - Use case: A rotary knob (volume, timer) that clicks per step, with sharper clicks at faster spin — Digital-Crown style. - Feel: Ratchet clicks whose sharpness rises with rotation speed, so fast spins feel mechanical and precise. - APIs: CHHapticEvent(.hapticTransient), RotateGesture / DragGesture - Minimum OS: iOS 13+ ### Core Haptics + SwiftUI ```swift import SwiftUI import CoreHaptics struct RatchetDial: View { @State private var angle: Angle = .zero @State private var lastStep = 0 @State private var lastTime = Date() @State private var engine: CHHapticEngine? let degreesPerStep = 15.0 var body: some View { Circle() .fill(.gray.gradient) .overlay(alignment: .top) { Capsule().fill(.white).frame(width: 6, height: 24).padding(8) } .frame(width: 180, height: 180) .rotationEffect(angle) .gesture( RotateGesture() .onChanged { value in angle = value.rotation let step = Int(value.rotation.degrees / degreesPerStep) if step != lastStep { // Sharper click when spinning fast let dt = Date().timeIntervalSince(lastTime) let sharpness = Float(min(1, 0.3 + 0.1 / max(dt, 0.02))) click(sharpness: sharpness) lastStep = step lastTime = Date() } } ) .onAppear { guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return } engine = try? CHHapticEngine() try? engine?.start() } } private func click(sharpness: Float) { guard let engine else { return } let event = CHHapticEvent(eventType: .hapticTransient, parameters: [ CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.6), CHHapticEventParameter(parameterID: .hapticSharpness, value: sharpness), ], relativeTime: 0) if let pattern = try? CHHapticPattern(events: [event], parameters: []), let player = try? engine.makePlayer(with: pattern) { try? player.start(atTime: CHHapticTimeImmediate) } } } ``` --- ## Canvas snap to alignment guide - Use case: Dragging an object in an editor until it snaps to a guide, grid line, or another object. - Feel: A single precise tick at the instant of alignment — the .alignment semantic haptic. - APIs: .sensoryFeedback(.alignment) - Minimum OS: iOS 17+ ### SwiftUI (iOS 17+) ```swift import SwiftUI struct SnappingCanvas: View { @State private var x: CGFloat = 60 @State private var snapped = false let guideX: CGFloat = 180 let snapRange: CGFloat = 12 var body: some View { ZStack { // The guide line Rectangle().fill(.blue.opacity(snapped ? 1 : 0.25)) .frame(width: 1).position(x: guideX, y: 150) RoundedRectangle(cornerRadius: 10) .fill(.orange) .frame(width: 70, height: 70) .position(x: x, y: 150) .gesture( DragGesture() .onChanged { value in let raw = value.location.x if abs(raw - guideX) < snapRange { x = guideX snapped = true // triggers .alignment once } else { x = raw snapped = false } } ) } .frame(height: 300) .sensoryFeedback(.alignment, trigger: snapped) { _, new in new } } } ``` ### Notes - The condition closure ("{ _, new in new }") fires the haptic only on false → true, i.e. only when snapping in, not out. --- ## Zoom / level change - Use case: Pinch-zoom that steps through discrete levels (1x, 2x, 3x) — a tick per level, camera-app style. - Feel: One .levelChange tick per zoom step; boundaries feel like gentle gear changes. - APIs: .sensoryFeedback(.levelChange), MagnifyGesture - Minimum OS: iOS 17+ ### SwiftUI (iOS 17+) ```swift import SwiftUI struct SteppedZoomView: View { @State private var scale: CGFloat = 1 @State private var committed: CGFloat = 1 private var level: Int { Int(scale.rounded()) } // 1x, 2x, 3x… var body: some View { Image(systemName: "photo") .font(.system(size: 80)) .frame(maxWidth: .infinity, maxHeight: 300) .scaleEffect(scale) .gesture( MagnifyGesture() .onChanged { value in scale = min(4, max(1, committed * value.magnification)) } .onEnded { _ in committed = CGFloat(level) // settle on a step withAnimation(.snappy) { scale = committed } } ) .sensoryFeedback(.levelChange, trigger: level) .overlay(alignment: .bottom) { Text("\(level)x").font(.caption.bold()).padding(6) .background(.thinMaterial, in: Capsule()) } } } ``` --- # Outcomes & notifications ## Success (task completed) - Use case: A user-initiated task finishes: form submitted, file saved, order placed. - Feel: Two quick taps with a rising feel — the system success pattern. - APIs: .sensoryFeedback(.success), UINotificationFeedbackGenerator(.success) - Minimum OS: iOS 17+ / iOS 10+ ### SwiftUI (iOS 17+) ```swift import SwiftUI struct SaveButton: View { @State private var saved = false var body: some View { Button { Task { try? await save() withAnimation { saved = true } } } label: { Label(saved ? "Saved" : "Save", systemImage: saved ? "checkmark" : "square.and.arrow.down") } .sensoryFeedback(.success, trigger: saved) { _, new in new } } func save() async throws { try await Task.sleep(for: .seconds(0.5)) } } ``` ### UIKit ```swift let haptic = UINotificationFeedbackGenerator() func submitForm() { haptic.prepare() // before the async work finishes api.submit { result in DispatchQueue.main.async { haptic.notificationOccurred(.success) // show confirmation UI at the same instant } } } ``` ### Notes - Play success only for outcomes the user asked for. Passive events (a sync finishing in the background) should stay silent. --- ## Error with shake animation - Use case: Wrong password or invalid input: the field shakes while the error haptic buzzes — the lock-screen pattern. - Feel: Three sharp buzzes synchronized with a horizontal shake. Unmistakably negative. - APIs: .sensoryFeedback(.error), UINotificationFeedbackGenerator(.error) - Minimum OS: iOS 17+ / iOS 10+ ### SwiftUI (iOS 17+) ```swift import SwiftUI struct PasscodeField: View { @State private var code = "" @State private var failedAttempts = 0 var body: some View { SecureField("Passcode", text: $code) .textFieldStyle(.roundedBorder) .modifier(Shake(trigger: failedAttempts)) .sensoryFeedback(.error, trigger: failedAttempts) .onSubmit { if code != "1234" { withAnimation(.default) { failedAttempts += 1 } code = "" } } } } /// Horizontal shake, synced to the same trigger as the haptic. struct Shake: ViewModifier, Animatable { var trigger: Int var animatableData: CGFloat { get { CGFloat(trigger) } set { phase = newValue } } private var phase: CGFloat = 0 init(trigger: Int) { self.trigger = trigger; self.phase = CGFloat(trigger) } func body(content: Content) -> some View { content.offset(x: sin(phase * .pi * 4) * 8) } } ``` ### UIKit ```swift func showLoginError(on field: UITextField) { UINotificationFeedbackGenerator().notificationOccurred(.error) let shake = CAKeyframeAnimation(keyPath: "transform.translation.x") shake.values = [0, -10, 10, -8, 8, -4, 4, 0] shake.duration = 0.4 field.layer.add(shake, forKey: "shake") } ``` ### Notes - Haptic and animation must share the same trigger/timeline — the multisensory sync is what makes it feel like one event. --- ## Warning (needs attention) - Use case: Something needs attention but is not fatal: low battery in a session, unsaved changes, weak signal. - Feel: Two taps with a falling feel — heavier than success, softer than error. - APIs: .sensoryFeedback(.warning), UINotificationFeedbackGenerator(.warning) - Minimum OS: iOS 17+ / iOS 10+ ### SwiftUI (iOS 17+) ```swift import SwiftUI struct RecordingView: View { @State private var storageAlmostFull = false var body: some View { VStack { Text("Recording…") if storageAlmostFull { Label("Less than 1 minute of storage left", systemImage: "exclamationmark.triangle.fill") .foregroundStyle(.orange) } } .sensoryFeedback(.warning, trigger: storageAlmostFull) { _, new in new } .task { try? await Task.sleep(for: .seconds(3)) withAnimation { storageAlmostFull = true } } } } ``` ### Notes - Warnings interrupt — use them only when the user should act now, not for informational badges. --- ## Payment complete (custom double-pulse) - Use case: An Apple Pay-style confirmation: a soft pre-tap followed by a strong full-body pulse. - Feel: A quiet "ready" tick, a beat of silence, then one deep confident thump. More ceremonial than plain success. - APIs: CHHapticEvent, CHHapticPattern - Minimum OS: iOS 13+ ### Core Haptics ```swift import CoreHaptics func playPaymentSuccess(engine: CHHapticEngine) throws { let ready = CHHapticEvent( eventType: .hapticTransient, parameters: [ CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.4), CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.7), ], relativeTime: 0) // A short continuous event reads as a "thump" rather than a tick let thump = CHHapticEvent( eventType: .hapticContinuous, parameters: [ CHHapticEventParameter(parameterID: .hapticIntensity, value: 1.0), CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.3), ], relativeTime: 0.25, duration: 0.13) let pattern = try CHHapticPattern(events: [ready, thump], parameters: []) let player = try engine.makePlayer(with: pattern) try engine.start() try player.start(atTime: CHHapticTimeImmediate) } // Fallback for devices without Core Haptics: // UINotificationFeedbackGenerator().notificationOccurred(.success) ``` ### Notes - Low sharpness + high intensity on the second event is what produces the deep "thump" instead of a click. - Time the thump to the checkmark animation completing, not to the network response. --- ## Connect / charging double thump - Use case: A device pairs, a charger connects, a session links — the MagSafe-style double thump. - Feel: Two round, low thumps in quick succession. Friendly and hardware-like. - APIs: CHHapticEvent, CHHapticPattern - Minimum OS: iOS 13+ ### Core Haptics ```swift import CoreHaptics func playConnectThump(engine: CHHapticEngine) throws { func thump(at time: TimeInterval, intensity: Float) -> CHHapticEvent { CHHapticEvent(eventType: .hapticTransient, parameters: [ CHHapticEventParameter(parameterID: .hapticIntensity, value: intensity), CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.25), // round ], relativeTime: time) } let pattern = try CHHapticPattern( events: [thump(at: 0, intensity: 0.9), thump(at: 0.16, intensity: 0.7)], parameters: []) let player = try engine.makePlayer(with: pattern) try engine.start() try player.start(atTime: CHHapticTimeImmediate) } ``` --- ## Countdown 3-2-1-Go - Use case: A workout or recording countdown: a tick per second, then a distinct stronger pattern on "go". - Feel: Three identical light ticks a second apart, resolved by a double-hit "go" that clearly breaks the rhythm. - APIs: CHHapticPattern (scheduled events), UIImpactFeedbackGenerator - Minimum OS: iOS 13+ ### Core Haptics (single scheduled pattern) ```swift import CoreHaptics /// Plays the whole countdown as ONE pattern — Core Haptics keeps the /// timing sample-accurate, unlike chained Timer callbacks. func playCountdown(engine: CHHapticEngine) throws { func tick(at t: TimeInterval, intensity: Float, sharpness: Float) -> CHHapticEvent { CHHapticEvent(eventType: .hapticTransient, parameters: [ CHHapticEventParameter(parameterID: .hapticIntensity, value: intensity), CHHapticEventParameter(parameterID: .hapticSharpness, value: sharpness), ], relativeTime: t) } let events = [ tick(at: 0.0, intensity: 0.5, sharpness: 0.8), // 3 tick(at: 1.0, intensity: 0.5, sharpness: 0.8), // 2 tick(at: 2.0, intensity: 0.5, sharpness: 0.8), // 1 tick(at: 3.0, intensity: 1.0, sharpness: 0.4), // GO tick(at: 3.12, intensity: 1.0, sharpness: 0.4), // GO (double hit) ] let pattern = try CHHapticPattern(events: events, parameters: []) let player = try engine.makePlayer(with: pattern) try engine.start() try player.start(atTime: CHHapticTimeImmediate) } ``` ### SwiftUI (simple, timer-driven) ```swift import SwiftUI struct CountdownView: View { @State private var count = 3 @State private var started = false let onGo: () -> Void var body: some View { Text(count > 0 ? "\(count)" : "GO") .font(.system(size: 90, weight: .black, design: .rounded)) .contentTransition(.numericText(countsDown: true)) .sensoryFeedback(trigger: count) { _, new in new > 0 ? .impact(weight: .light, intensity: 0.6) : .success } .task { for _ in 0..<3 { try? await Task.sleep(for: .seconds(1)) withAnimation { count -= 1 } } onGo() } } } ``` ### Notes - For rhythm-critical sequences, schedule one Core Haptics pattern instead of firing haptics from timers — timers drift. --- # Custom Core Haptics patterns ## Heartbeat (lub-dub loop) - Use case: A health, meditation, or connection feature that plays a realistic looping heartbeat. - Feel: A strong round "lub" and a softer "dub", repeating ~60 bpm. Low sharpness keeps it organic. - APIs: CHHapticAdvancedPatternPlayer, loopEnabled - Minimum OS: iOS 13+ ### Core Haptics ```swift import CoreHaptics final class HeartbeatPlayer { private let engine: CHHapticEngine private var player: CHHapticAdvancedPatternPlayer? init?() { guard CHHapticEngine.capabilitiesForHardware().supportsHaptics, let engine = try? CHHapticEngine() else { return nil } self.engine = engine try? engine.start() } func start(bpm: Double = 60) throws { let beat = 60.0 / bpm func hit(at t: TimeInterval, intensity: Float) -> CHHapticEvent { CHHapticEvent(eventType: .hapticTransient, parameters: [ CHHapticEventParameter(parameterID: .hapticIntensity, value: intensity), CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.15), ], relativeTime: t) } // lub ... dub ......... (loop) let events = [hit(at: 0, intensity: 1.0), hit(at: 0.14, intensity: 0.6)] let pattern = try CHHapticPattern(events: events, parameters: []) player = try engine.makeAdvancedPlayer(with: pattern) player?.loopEnabled = true player?.loopEnd = beat // loop length = one full beat try player?.start(atTime: CHHapticTimeImmediate) } func stop() { try? player?.stop(atTime: CHHapticTimeImmediate) } } // let heart = HeartbeatPlayer() // try? heart?.start(bpm: 72) ``` ### Notes - loopEnabled + loopEnd on an advanced player is far more stable than restarting the pattern with a Timer. - Drive bpm from live HealthKit heart-rate samples for a "feel someone's pulse" feature. --- ## Drum roll / escalating anticipation - Use case: Building suspense before a reveal (prize wheel, match result): ticks accelerate and intensify, then release. - Feel: Ticks start slow and soft, compress in time and grow in strength, ending in a full-intensity finale hit. - APIs: CHHapticPattern, generated transient sequence - Minimum OS: iOS 13+ ### Core Haptics ```swift import CoreHaptics /// Ticks accelerate over `duration` seconds, then a finale thump. func playDrumRoll(engine: CHHapticEngine, duration: TimeInterval = 2.0) throws { var events: [CHHapticEvent] = [] var t: TimeInterval = 0 var gap: TimeInterval = 0.20 // first interval let minGap: TimeInterval = 0.035 while t < duration { let progress = Float(t / duration) // 0 → 1 events.append(CHHapticEvent( eventType: .hapticTransient, parameters: [ CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.3 + 0.7 * progress), CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.4 + 0.5 * progress), ], relativeTime: t)) t += gap gap = max(minGap, gap * 0.86) // accelerate } // Finale: deep full-strength thump events.append(CHHapticEvent( eventType: .hapticContinuous, parameters: [ CHHapticEventParameter(parameterID: .hapticIntensity, value: 1.0), CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.2), ], relativeTime: duration + 0.1, duration: 0.15)) let pattern = try CHHapticPattern(events: events, parameters: []) let player = try engine.makePlayer(with: pattern) try engine.start() try player.start(atTime: CHHapticTimeImmediate) } ``` --- ## Game explosion / heavy collision - Use case: A game impact: sharp initial crack, then a rumble that decays over half a second. - Feel: An instant sharp hit followed by a low rumble fading out — like debris settling. - APIs: CHHapticEvent, CHHapticParameterCurve - Minimum OS: iOS 13+ ### Core Haptics ```swift import CoreHaptics func playExplosion(engine: CHHapticEngine, magnitude: Float = 1.0) throws { // scale 0–1 by event size // 1) The crack let crack = CHHapticEvent(eventType: .hapticTransient, parameters: [ CHHapticEventParameter(parameterID: .hapticIntensity, value: magnitude), CHHapticEventParameter(parameterID: .hapticSharpness, value: 1.0), ], relativeTime: 0) // 2) The rumble let rumble = CHHapticEvent(eventType: .hapticContinuous, parameters: [ CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.8 * magnitude), CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.1), ], relativeTime: 0.02, duration: 0.5) // 3) Decay curve: rumble fades to zero let decay = CHHapticParameterCurve( parameterID: .hapticIntensityControl, controlPoints: [ .init(relativeTime: 0.02, value: 1.0), .init(relativeTime: 0.52, value: 0.0), ], relativeTime: 0) let pattern = try CHHapticPattern(events: [crack, rumble], parameterCurves: [decay]) let player = try engine.makePlayer(with: pattern) try engine.start() try player.start(atTime: CHHapticTimeImmediate) } ``` ### Notes - Scale magnitude with in-game distance to the explosion for spatial believability. - In games, also pair with CHHapticEngine(audioSession:) so haptics and sound stay sample-locked. --- ## AHAP files (designed patterns as assets) - Use case: Ship haptic patterns as JSON assets designers can iterate on without recompiling — the standard for game/media haptics. - Feel: Whatever you design: AHAP expresses the same events, parameters, and curves as code, in a portable file. - APIs: CHHapticEngine.playPattern(from: URL), AHAP - Minimum OS: iOS 13+ ### Swift (loading) ```swift import CoreHaptics func playAHAP(named name: String, engine: CHHapticEngine) { guard let url = Bundle.main.url(forResource: name, withExtension: "ahap") else { return } do { try engine.start() try engine.playPattern(from: url) } catch { print("Failed to play \(name).ahap: \(error)") } } // playAHAP(named: "Sparkle", engine: HapticEngine.shared.engine) ``` ### Sparkle.ahap ```json { "Version": 1.0, "Metadata": { "Project": "MyApp", "Created": "2026", "Description": "Sharp tick followed by a soft fading shimmer" }, "Pattern": [ { "Event": { "Time": 0.0, "EventType": "HapticTransient", "EventParameters": [ { "ParameterID": "HapticIntensity", "ParameterValue": 0.8 }, { "ParameterID": "HapticSharpness", "ParameterValue": 1.0 } ] } }, { "Event": { "Time": 0.12, "EventType": "HapticContinuous", "EventDuration": 0.3, "EventParameters": [ { "ParameterID": "HapticIntensity", "ParameterValue": 0.5 }, { "ParameterID": "HapticSharpness", "ParameterValue": 0.6 } ] } }, { "ParameterCurve": { "ParameterID": "HapticIntensityControl", "Time": 0.12, "ParameterCurveControlPoints": [ { "Time": 0.0, "ParameterValue": 1.0 }, { "Time": 0.3, "ParameterValue": 0.0 } ] } } ] } ``` ### Notes - AHAP can also carry AudioCustom events that play sound sample-locked to the haptics. - Tools like Captain AHAP or Lofelt Composer let designers author these visually. --- ## Audio + haptic sync (CoreHaptics audio events) - Use case: A confirmation that is heard and felt as one event — haptic and sound scheduled on the same clock. - Feel: A click you hear and feel at the exact same instant. Scheduling both in one pattern removes all drift. - APIs: CHHapticEvent(.audioContinuous / .audioCustom), registerAudioResource - Minimum OS: iOS 13+ ### Core Haptics ```swift import CoreHaptics func playClickWithSound(engine: CHHapticEngine) throws { // Register a bundled sound once (cache the ID in real code) guard let url = Bundle.main.url(forResource: "click", withExtension: "wav") else { return } let audioID = try engine.registerAudioResource(url) let haptic = CHHapticEvent(eventType: .hapticTransient, parameters: [ CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.9), CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.8), ], relativeTime: 0) let sound = CHHapticEvent(audioResourceID: audioID, parameters: [ CHHapticEventParameter(parameterID: .audioVolume, value: 0.8), ], relativeTime: 0) let pattern = try CHHapticPattern(events: [haptic, sound], parameters: []) let player = try engine.makePlayer(with: pattern) try engine.start() try player.start(atTime: CHHapticTimeImmediate) } ``` ### Notes - This is how system sounds like keyboard clicks stay perfectly aligned with their haptics. - Unregister audio resources you no longer need to free engine memory. ---