Why Haptics Matter More Than You Think
When I watch people use Lunair, something interesting happens. After a minute or two of following the breathing animation, most users close their eyes. Makes sense — you are trying to relax. But the moment they close their eyes, they lose the visual guide.
This is where haptics become the primary interface.
The Haptic Vocabulary
Apple's haptic engine is surprisingly expressive if you treat it as a design language rather than a notification mechanism. In Lunair, I developed a small vocabulary of haptic patterns that users learn intuitively:
class BreathingHapticEngine {
private let softGenerator = UIImpactFeedbackGenerator(style: .soft)
private let lightGenerator = UIImpactFeedbackGenerator(style: .light)
private let rigidGenerator = UIImpactFeedbackGenerator(style: .rigid)
func prepareAll() {
softGenerator.prepare()
lightGenerator.prepare()
rigidGenerator.prepare()
}
func signalInhaleStart() {
softGenerator.impactOccurred(intensity: 0.7)
}
func signalExhaleStart() {
lightGenerator.impactOccurred(intensity: 0.5)
}
func signalSessionComplete() {
let pattern: [TimeInterval] = [0, 0.15, 0.3]
for (index, delay) in pattern.enumerated() {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
self.rigidGenerator.impactOccurred(
intensity: CGFloat(pattern.count - index) / CGFloat(pattern.count)
)
}
}
}
}
The key insight: inhale gets a softer, slightly stronger tap. Exhale gets a lighter, gentler tap. Your brain maps these to the actions after just two or three breathing cycles.
Preparing Generators Ahead of Time
One of the most common haptic mistakes in iOS development is not preparing your feedback generators. There is a perceptible delay between creating a generator and it being ready to fire. In a breathing app where timing is everything, that delay breaks the experience:
// Called when a breathing session starts
func sessionWillBegin() {
hapticEngine.prepareAll()
// Generators are now "spun up" and ready
// First haptic will fire with zero delay
}
I prepare generators at session start rather than at first use. The Taptic Engine stays warm for about 2 seconds after preparation, so for continuous use during a breathing session, you really only need to prepare once.
Core Haptics for Advanced Patterns
For the premium breathing patterns in Lunair, I use Core Haptics instead of the basic UIFeedbackGenerator. This lets me create continuous haptic textures that can ramp up and down — matching the breathing curve:
func createInhalePattern() throws -> CHHapticPattern {
let rampUp = CHHapticParameterCurve(
parameterID: .hapticIntensityControl,
controlPoints: [
.init(relativeTime: 0, value: 0.1),
.init(relativeTime: 0.5, value: 0.6),
.init(relativeTime: 1.0, value: 0.3)
],
relativeTime: 0
)
let event = CHHapticEvent(
eventType: .hapticContinuous,
parameters: [
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.3)
],
relativeTime: 0,
duration: inhaleDuration
)
return try CHHapticPattern(events: [event], parameterCurves: [rampUp])
}
This creates a haptic sensation that swells and fades with the breath — a gentle vibration that intensifies as lungs fill and softens as you approach the hold phase.
Respecting User Preferences
Not everyone wants haptics. Some users find them distracting. In Lunair, haptic intensity is adjustable from off to strong, and the setting persists:
enum HapticIntensity: String, CaseIterable {
case off, subtle, moderate, strong
var multiplier: CGFloat {
switch self {
case .off: return 0
case .subtle: return 0.3
case .moderate: return 0.6
case .strong: return 1.0
}
}
}
Testing Haptics
You cannot test haptics in the simulator. This seems obvious, but I spent an embarrassing amount of time wondering why my haptic code "was not working" before remembering this. Always test on a physical device, and test with your eyes closed — that is the actual use case.
Haptics turned out to be one of Lunair's most praised features. Users consistently mention the eyes-closed experience in reviews. It is a small investment that fundamentally changes how people interact with the app.
Top comments (0)