markbattistella/hapticsmanager
HapticsManager is a Swift package that provides a modular and easy-to-use interface for implementing haptic feedback in your applications. It integrates seamlessly with SwiftUI, enabling you to enhance user experience through customisable tactile feedback.
Features
- Custom Haptics: Easily define and trigger haptic feedback.
- SwiftUI Extensions: Add haptic feedback to SwiftUI views in a declarative way.
- User Preferences: Enable or disable haptic feedback based on user settings through simple configuration.
- Custom Haptic Patterns: Extend and create your own complex haptic patterns.
Installation
Add HapticsManager to your Swift project using Swift Package Manager.
dependencies: [
.package(url: "https://github.com/markbattistella/HapticsManager", from: "26.5.23")
]Requirements
- Swift 6.0+ (package targets build in Swift 6 language mode)
- iOS 14.0+
The package ships two targets:
| Target | Description | | --- | --- | | HapticsManager | Core haptic engine, SwiftUI modifiers, and the CustomHaptic protocol | | HapticsManagerPresets | Optional add-on — 151 ready-to-use CoreHaptics patterns |
Add only what you need to your target's dependencies:
.target(
name: "MyApp",
dependencies: [
.product(name: "HapticsManager", package: "HapticsManager"),
// Add this only if you want the built-in preset library:
.product(name: "HapticsManagerPresets", package: "HapticsManager"),
]
)Why use this
This package works similarly to the SwiftUI .sensoryFeedback API but adds more flexibility by letting you configure whether haptics are enabled globally via UserDefaults.
With HapticsManager, you can easily determine if haptic feedback should be available for the user — simplifying your workflow and maintaining familiar, declarative syntax.
The main advantage is that you can control HapticUserDefaultsKey.hapticEffectsEnabled centrally, allowing or conditionally enabling haptic feedback without rewriting logic in every trigger. .sensoryFeedback would require you to implement this logic each time it's used.
Usage
There are four ways to use HapticsManager:
- Static Action: This is the simplest method, used when you want to trigger haptic feedback for a particular state change. It's consistent and straightforward — ideal when the haptic feedback needs to occur every time a specific condition (like a state variable changing) is met.
- Static Action with Condition: This approach adds more control compared to the standard static action. Here, you specify a set of conditions to determine when the haptic feedback should be triggered. This allows you to handle more nuanced scenarios — such as only playing feedback when transitioning from one specific state to another, while ignoring others.
- Dynamic Action: The most flexible of the three, dynamic actions let you determine the type of haptic feedback based on the old and new values during a state change. This means you can implement complex feedback behaviours that respond differently based on how the state transitions, allowing for a more dynamic and tailored user experience.
- Immediate Action: Trigger feedback directly from a tap gesture or from inside an action handler with
inlineHaptic(_:).
Static Action
The static action format allows you to trigger haptic feedback consistently and simply. In the example below, haptic feedback is triggered whenever the isSuccess state changes.
@State private var isSuccess: Bool = false
Button("isSuccess: \(isSuccess)") {
isSuccess.toggle()
}
.hapticFeedback(.notification(.warning), trigger: isSuccess)Static Action with Condition
You can also use a condition to control when the haptic feedback should be triggered, allowing for more focused control over when feedback occurs.
Old and New Values
enum Phase { case inactive, active, completed }
@State private var phase: Phase = .inactive
Button("Update phase") {
switch phase {
case .inactive: phase = .active
case .active: phase = .completed
case .completed: phase = .inactive
}
}
.hapticFeedback(.impact(.medium), trigger: phase) { oldValue, newValue in
oldValue != .completed && newValue == .completed
}New Value Only
enum Phase { case inactive, active, completed }
@State private var phase: Phase = .inactive
Button("Update phase") {
switch phase {
case .inactive: phase = .active
case .active: phase = .completed
case .completed: phase = .inactive
}
}
.hapticFeedback(.impact(.medium), trigger: phase) { newValue in
newValue == .completed
}No Parameters
@State private var phase: Bool = false
Button("Toggle Phase") {
phase.toggle()
}
.hapticFeedback(.impact(.medium), trigger: phase) {
// Haptic feedback triggered
}Dynamic Action
The dynamic action approach gives you full control over both the type of feedback and the conditions under which it's triggered.
Old and New Values
enum LoadingState { case ready, success, failure }
@State private var loadingState: LoadingState = .ready
Button("Update loading state") {
switch loadingState {
case .ready: loadingState = .success
case .success: loadingState = .failure
case .failure: loadingState = .ready
}
}
.hapticFeedback(trigger: loadingState) { oldValue, newValue in
switch (oldValue, newValue) {
case (.failure, .ready):
return .notification(.warning)
case (.ready, .success):
return .notification(.success)
case (.success, .failure):
return .notification(.error)
default:
return nil
}
}New Values Only
enum LoadingState { case ready, success, failure }
@State private var loadingState: LoadingState = .ready
Button("Update loading state") {
switch loadingState {
case .ready: loadingState = .success
case .success: loadingState = .failure
case .failure: loadingState = .ready
}
}
.hapticFeedback(trigger: loadingState) { newValue in
switch newValue {
case .success: return .notification(.success)
case .failure: return .notification(.error)
default: return nil
}
}No Parameters
@State private var isLoading: Bool = false
Button("Toggle Loading") {
isLoading.toggle()
}
.hapticFeedback(trigger: isLoading) {
return .impact(.heavy)
}Immediate Haptic Feedback
In addition to the state-driven haptic APIs, HapticsManager offers Immediate Action APIs designed to trigger haptic feedback directly from user interaction.
These are ideal when you want haptics to fire each time the user taps a view, or when you want full control inside a Button action.
There are two forms:
- Declarative Tap-Driven Haptic Feedback:
.hapticFeedback(_:) - Imperative Haptic Trigger:
.inlineHaptic(_:)
Each serves a different role.
Declarative Immediate Action (tap-triggered)
Attach haptic feedback to any tappable view.
The haptic is triggered when the user taps the view, not when state changes or when the view re-renders.
Text("Tap Me")
.padding()
.hapticFeedback(.impact(.medium))This is useful for:
- Text, Image, Shape
- Custom tappable areas
- Views where you want to provide tactile response on tap
[!NOTE] In certain contexts—such as Form, List, toolbar items, or heavily styled buttons—SwiftUI’s internal gesture system may interfere with tap delivery. In those cases, use
inlineHaptic(_:)instead.
Imperative Immediate Action (inlineHaptic)
Use this method to trigger a haptic directly inside your action handler.
Button("Submit") {
inlineHaptic(.notification(.success))
submitForm()
}This approach is:
- The most reliable for Buttons
- Runs at the point it is called when haptics are available and enabled
- Not dependent on SwiftUI gesture routing
- Safe inside async tasks, callbacks, gesture recognisers, etc.
- Automatically hops to the main actor when called off the main thread
This mirrors UIKit’s pattern:
UIImpactFeedbackGenerator(style: .medium).impactOccurred()Recommended usage
Use inlineHaptic(:) instead of .hapticFeedback(:) when:
- Inside a Button
- Inside Form
- Inside List
- Inside toolbar actions
- When the action may be triggered programmatically
- When you want exactly one haptic per action execution
Configuring Haptic Settings
HapticsManager includes a .hapticEffectsEnabled UserDefaults key, allowing you to dynamically enable or disable haptics based on user settings.
This is helpful if you want to add a settings screen for toggling haptics, or if you need an overall logic to control haptics — for example, making it a premium feature.
Built-in UserDefaults Suite
Haptics are enabled by default. To override that, write to the package's publicly exposed UserDefaults.haptics suite:
Button("Turn haptics off") {
UserDefaults.haptics.set(false, for: HapticUserDefaultsKey.hapticEffectsEnabled)
}
Button("Turn haptics on") {
UserDefaults.haptics.set(true, for: HapticUserDefaultsKey.hapticEffectsEnabled)
}[!IMPORTANT] The package only reads from the internal
.hapticssuite to prevent unintended clashes across different parts of the application.
Extending Haptic Feedback Types
If the built-in feedback types are not sufficient, you can create custom haptic patterns using the .custom(any CustomHaptic) HapticFeedback case.
Creating a Custom Feedback
To add a custom haptic feedback type:
- Define a
CustomHapticconforming type.CustomHapticisSendable, and playback is main-actor isolated because Core Haptics work is performed on the shared haptic engine.
enum MyCustomHapticPattern: CustomHaptic {
case complexSuccess
@MainActor
func play() {
switch self {
case .complexSuccess:
playComplexSuccessHaptic()
}
}
}- Implement the playback helper that builds your haptic pattern:
[!NOTE] When creating custom haptic patterns, you do not need to create or manage a
CHHapticEngineyourself.CustomHapticprovides a shared engine throughhapticEngine, powered internally byHapticEngineManager, ensuring the engine is created once and kept alive for the lifetime of the app.
extension MyCustomHapticPattern {
// From HWS: https://www.hackingwithswift.com/books/ios-swiftui/adding-haptic-effects
@MainActor
func playComplexSuccessHaptic() {
var events = [CHHapticEvent]()
for i in stride(from: 0, to: 1, by: 0.1) {
let intensity = CHHapticEventParameter(parameterID: .hapticIntensity, value: Float(i))
let sharpness = CHHapticEventParameter(parameterID: .hapticSharpness, value: Float(i))
let event = CHHapticEvent(
eventType: .hapticTransient,
parameters: [intensity, sharpness],
relativeTime: i
)
events.append(event)
}
for i in stride(from: 0, to: 1, by: 0.1) {
let intensity = CHHapticEventParameter(parameterID: .hapticIntensity, value: Float(1 - i))
let sharpness = CHHapticEventParameter(parameterID: .hapticSharpness, value: Float(1 - i))
let event = CHHapticEvent(
eventType: .hapticTransient,
parameters: [intensity, sharpness],
relativeTime: 1 + i
)
events.append(event)
}
guard let pattern = try? CHHapticPattern(events: events, parameters: []) else {
return
}
// Play using the shared haptic engine
playPattern(pattern)
}
}- Use the custom feedback in your app:
@State private var isSuccess: Bool = false
Button("isSuccess: \(isSuccess)") {
isSuccess.toggle()
}
.hapticFeedback(.custom(MyCustomHapticPattern.complexSuccess), trigger: isSuccess)Using your own engine configuration
Within a custom haptic, you can use the same engine from HapticsManager while configuring your own player behaviour:
@MainActor
func playScheduledPattern(events: [CHHapticEvent]) {
guard
let pattern = try? CHHapticPattern(events: events, parameters: []),
let engine = try? hapticEngine,
let player = try? engine.makePlayer(with: pattern)
else {
return
}
try? player.start(atTime: 0)
}Built-in Preset Library
The optional HapticsManagerPresets target ships 151 named haptic patterns sourced from the Pulsar open-source library (MIT licence).
Each preset uses CoreHaptics directly. Presets may include transient events, continuous events shaped by intensity and sharpness parameter curves, or both. Some Pulsar presets are continuous-only, so their transient event lists are intentionally empty. When both layers are present, HapticsManager starts the continuous and transient patterns together, matching Pulsar’s player behaviour.
Usage
Import the target and pass any HapticPreset case to .custom(...):
import HapticsManager
import HapticsManagerPresets
// State-driven
Button("Send") { isSent = true }
.hapticFeedback(.custom(HapticPreset.heartbeat), trigger: isSent)
// Tap-driven
Text("Tap me")
.hapticFeedback(.custom(HapticPreset.buzz))
// Inline (inside a Button action or callback)
Button("Submit") {
inlineHaptic(.custom(HapticPreset.fanfare))
submitForm()
}You can also call .play() directly from a main-actor context when you need full control over timing:
Button("Preview") {
HapticPreset.heartbeat.play()
}Available presets
<details> <summary>Show all 151 presets</summary>
| | | | | | --- | --- | --- | --- | | afterglow | aftershock | alarm | anvil | | applause | ascent | balloonPop | barrage | | bassDrop | batter | bellToll | blip | | bloom | bongo | boulder | breakingWave | | breath | buildup | burst | buzz | | cadence | cameraShutter | canter | cascade | | castanets | catPaw | charge | chime | | chip | chirp | clamor | clasp | | cleave | coil | coinDrop | combinationLock | | crescendo | dewdrop | dirge | dissolve | | dogBark | drone | engineRev | exhale | | explosion | fadeOut | fanfare | feather | | finale | fingerDrum | firecracker | fizz | | flare | flick | flinch | flourish | | flurry | flush | gallop | gavel | | glitch | guitarStrum | hail | hammer | | heartbeat | herald | hoofBeat | ignition | | impact | jolt | keyboardMechanical | keyboardMembrane | | knell | knock | lament | latch | | lighthouse | lilt | lock | lope | | march | metronome | murmur | nudge | | passingCar | patter | peal | peck | | pendulum | ping | pip | piston | | plink | plummet | plunk | poke | | pound | powerDown | propel | pulse | | pummel | push | radar | rain | | ramp | rap | ratchet | rebound | | ripple | rivet | rustle | shockwave | | snap | sonar | spark | spin | | stagger | stamp | stampede | stomp | | stoneSkip | strike | summon | surge | | sway | sweep | swell | syncopate | | throb | thud | thump | thunder | | thunderRoll | tickTock | tidalSurge | tideSwell | | tremor | trigger | triumph | trumpet | | typewriter | unfurl | vortex | wane | | warDrum | waterfall | wave | wisp | | wobble | woodpecker | zipper | |
</details>
Attribution
The preset data is derived from Pulsar by Krzysztof Piaskowy / Software Mansion, and is used under the MIT Licence. The full licence text is included in the ACKNOWLEDGEMENTS file.
Contributing
Contributions are always welcome! Feel free to submit a pull request or open an issue for any suggestions or improvements you have.
License
HapticsManager is licensed under the MIT License. See the LICENCE file for more details.
Package Metadata
Repository: markbattistella/hapticsmanager
Default branch: main
README: README.md