Skip to content
halilozel1903Public

About

Expressive haptics for iPhone and Apple Watch: ready-made Core Haptics and WatchKit patterns, a tiny DSL, SwiftUI modifiers and a playground app.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

HapticKit: expressive haptics for iPhone and Apple Watch

CI Swift 6.2 iOS 17+ watchOS 10+ Core Haptics and WatchKit Swift Package Manager MIT License

HapticKit lets you describe a haptic once and play it on iPhone and Apple Watch. Write a pattern with a tiny result-builder DSL, or pick one of nine ready-made patterns (success, warning, error, heartbeat, countdown tick, level up, notification, typing, rattle). HapticKit compiles it to Core Haptics events and intensity curves on iPhone and to a timed sequence of WatchKit haptics on Apple Watch. A .haptic(_:trigger:) SwiftUI modifier plays it, a reduce haptics preference is respected everywhere, and a timeline view draws any pattern so you can see what you will feel.

let knock = HapticPattern("Knock") {
    Tap(.sharp, intensity: 0.8)
    Pause(0.08)
    Rumble(duration: 0.3, intensity: .ramp(0.2...1))
}

Button("Save") { saves += 1 }
    .haptic(knock, trigger: saves)

Screenshots

Captured from the example apps on iOS 26 and watchOS 26 simulators by CI. You can't see a haptic in a screenshot, so the apps draw every pattern: taps are bars as tall as they are strong, rumbles are filled envelopes, and the color shows the sharpness, from soft (pink) to crisp (cyan).

Pattern library Heartbeat Pattern builder
Haptic Playground: a list of nine built-in patterns, each with an icon, a small timeline of bars and envelopes, its tap count and duration, and a play button The heartbeat pattern: a large timeline with three lub-dubs and a playhead on the second beat, a play button, duration, taps, rumbles and watch steps, and the Swift code of the pattern with syntax colors The pattern builder: a timeline of the pattern being edited, a list of taps, pauses and a selected rumble with sliders for duration, start and end intensity and sharpness

On Apple Watch:

Patterns Play
The watch app: a list of patterns, each with an icon and a small timeline The heartbeat pattern on Apple Watch: its timeline with a playhead, a big pink play button and 6 haptics in 2.28 seconds

Features

  • A tiny DSL: HapticPattern { … } with Tap, Pause, Rumble and Repeat, plus if, else, switch and for. Patterns nest, compare, hash and are Sendable.
  • Intensity envelopes: constant (0.7), .ramp(0.2...1), .ramp(from: 1, to: 0), .curve([0.2, 1, 0.4]) or .curve(points:) at any time. Sharpness presets from .soft to .crisp, or any value.
  • Nine built-in patterns: .success, .warning, .error, .heartbeat, .countdownTick, .levelUp, .notification, .typing and .rattle, all in HapticPattern.builtIns.
  • Core Haptics compiler: coreHapticsDescription() turns a pattern into plain transient and continuous events and hapticIntensityControl curves (split at 16 control points, reset after each shaped rumble). On iOS makeHapticPattern() builds the CHHapticPattern.
  • WatchKit compiler: watchHapticSequence() turns a pattern into WKHapticType steps with times: soft taps click, strong taps start, rumbles pulse with their envelope, and steps the Taptic Engine would merge are dropped. Patterns with a system equivalent play it (.success, .failure, .retry, .notification…).
  • Engines: HapticEngine is a small protocol. LiveHapticEngine plays on the device (a CHHapticEngine on iPhone, WKInterfaceDevice on Apple Watch); RecordingHapticEngine records what was played for tests and previews.
  • Accessibility: pass the user's choice as a HapticPreference (.full, .reduced, .off). Reduced patterns keep their timing at half strength, with short constant rumbles and fewer taps.
  • SwiftUI: .haptic(_:trigger:) like sensoryFeedback, a closure variant that picks the pattern from the old and new value, and .hapticEngine(_:) / .hapticPreference(_:) environment values.
  • sensoryFeedback interop: built-in patterns know their closest SensoryFeedback. The modifier falls back to it where custom haptics are not supported (iPad), or always with playback: .system.
  • See your haptics: HapticTimelineView draws taps, envelopes, a time grid and a playhead; HapticTimelineLayout is the plain geometry behind it.
  • Code output: pattern.swiftCode writes any pattern back as DSL source, for editors and code previews.
  • Swift 6 strict concurrency, zero dependencies, compilation and layout tested with Swift Testing.

Installation

In Xcode choose File › Add Package Dependencies… and enter:

https://github.com/halilozel1903/HapticKit

Or add it to Package.swift:

dependencies: [
    .package(url: "https://github.com/halilozel1903/HapticKit", from: "1.0.0")
]

Usage

Play a pattern

import HapticKit
import SwiftUI

struct CheckoutButton: View {
    @State private var orders = 0
    @State private var failures = 0

    var body: some View {
        Button("Place Order") { placeOrder() }
            .haptic(.success, trigger: orders)
            .haptic(.error, trigger: failures)
    }
}

Pick the pattern from the change:

Stepper("Level \(level)", value: $level)
    .haptic(trigger: level) { old, new in new > old ? .levelUp : .countdownTick }

Or play one yourself:

LiveHapticEngine.shared.play(.heartbeat)
LiveHapticEngine.shared.play(.heartbeat, preference: .reduced)
LiveHapticEngine.shared.stop()

Write your own

let doorbell = HapticPattern("Doorbell") {
    Tap(.crisp, intensity: 1)                         // ding
    Pause(0.25)
    Tap(.dull, intensity: 0.7)                        // dong
    Rumble(duration: 0.4, intensity: .ramp(from: 0.6, to: 0), sharpness: .soft)
}

let alarm = HapticPattern("Alarm") {
    Repeat(4) {
        Rumble(duration: 0.15, intensity: .curve([0.3, 1, 0.3]), sharpness: .sharp)
        Pause(0.1)
    }
    if isUrgent {
        HapticPattern.error                           // patterns nest
    }
}
Element Meaning
Tap(_ sharpness: = .medium, intensity: = 1) A transient tap. It takes 40 ms of the timeline, so taps written back to back are felt one by one.
Pause(_ seconds:) Silence.
Rumble(duration:intensity: = 1, sharpness: = .soft) A continuous vibration that follows an intensity envelope.
Repeat(_ count:) { … } Its steps, count times.
HapticPattern Another pattern, inline.
Intensity Envelope
0.7 Constant
.ramp(0.2...1) Swells from 0.2 to 1
.ramp(from: 1, to: 0) Fades out
.curve([0.2, 1, 0.4]) Values spread evenly over the rumble
.curve(points: [HapticCurvePoint(time: 0.2, value: 1), …]) Points at any fraction of the rumble

Sharpness: .soft (0.1), .dull (0.3), .medium (0.5), .sharp (0.75), .crisp (1), or any value in 0...1.

A pattern is a value you can inspect:

HapticPattern.heartbeat.duration        // 2.28
HapticPattern.heartbeat.events          // taps and rumbles with their times
HapticPattern.heartbeat.summary         // "6 taps · 3 rumbles · 2.28 s"
HapticPattern.success.swiftCode         // HapticPattern("Success") { Tap(.sharp, intensity: 0.6) … }

Respect "reduce haptics"

iOS has no public setting for fewer haptics, so offer one and pass it in. Every engine call and the SwiftUI modifiers respect it:

@AppStorage("reduceHaptics") private var reduceHaptics = false

var body: some View {
    ContentView()
        .hapticPreference(HapticPreference(reduceHaptics: reduceHaptics))
}
Preference Plays
.full The pattern as written
.reduced Same timing, half the strength, rumbles cut to 150 ms of constant vibration, taps within 150 ms of the previous one left out
.off Nothing, not even the system feedback

pattern.reduced() and pattern.adjusted(for:) give you the adjusted pattern directly.

What it compiles to

let description = HapticPattern.warning.coreHapticsDescription()
description.events            // 2 transient taps and 1 continuous event at 0.7
description.parameterCurves   // 1 hapticIntensityControl curve: 1 → 0.14, then back to 1

let pattern = try HapticPattern.warning.makeHapticPattern()   // CHHapticPattern, iOS only
HapticPattern.heartbeat.watchHapticSequence().steps
// start at 0, start at 0.22, start at 0.76, start at 0.98, start at 1.52, start at 1.74

HapticPattern.success.watchHapticSequence().steps                          // [.success at 0]
HapticPattern.success.watchHapticSequence(usesSystemHaptic: false).steps   // [.start at 0, .start at 0.12]
Pattern step iPhone (Core Haptics) Apple Watch (WatchKit)
Tap Transient event .click below half strength, .start above
Rumble, constant Continuous event A pulse every 120 ms
Rumble, shaped Continuous event at the peak + intensity curve Pulses with the envelope's strength, weak ones left out
System equivalent (the pattern itself) .success, .failure, .retry, .notification, .directionUp…

Pulses closer than 100 ms are dropped on Apple Watch because the Taptic Engine merges them; pass minimumSpacing: to change that.

SwiftUI sensoryFeedback

Built-in patterns carry their closest system feedback:

HapticPattern.success.sensoryFeedback     // .success
HapticPattern.levelUp.sensoryFeedback     // .increase
HapticPattern.heartbeat.sensoryFeedback   // nil

.sensoryFeedback(HapticPattern.warning.sensoryFeedback!, trigger: value)
.haptic(.warning, trigger: value, playback: .system)       // the same, through HapticKit

.haptic(_:trigger:) uses playback: .automatic: the custom pattern where the device supports Core Haptics, the system feedback elsewhere. HapticPattern(.selection) goes the other way and gives you a pattern for a system style.

Draw a pattern

HapticTimelineView(pattern: .heartbeat)
    .frame(height: 120)

HapticTimelineView(pattern: .levelUp, progress: 0.4, showsTimeLabels: false)   // with a playhead
HapticTimelineView(pattern: .typing, span: 2)                                    // shared scale

VoiceOver reads it as "Heartbeat haptic timeline, 6 taps · 3 rumbles · 2.28 s".

Tests and previews

@MainActor
@Test func savingPlaysSuccess() {
    let engine = RecordingHapticEngine()
    let model = EditorModel(haptics: engine)

    model.save()

    #expect(engine.played == [.success])
}
#Preview {
    EditorView()
        .hapticEngine(RecordingHapticEngine())
}

Conform to HapticEngine to route haptics anywhere else, for example to a game controller:

@MainActor
final class ControllerHaptics: HapticEngine {
    var supportsHaptics: Bool { true }
    func play(_ pattern: HapticPattern) { … }   // pattern.coreHapticsDescription()
    func stop() { … }
}

Example apps

The Example folder contains Haptic Playground for iPhone (the pattern library, a detail page with the timeline, the code and what each platform plays, and a pattern builder with live code) and Haptics, a standalone Apple Watch app with the pattern list and a big play button. The iPhone app has the Reduce Haptics switch in its settings section. Both use XcodeGen so no project file has to live in the repo:

brew install xcodegen
cd Example && xcodegen generate
open HapticPlayground.xcodeproj

Run the HapticPlayground scheme on an iPhone and the HapticWatch scheme on an Apple Watch to feel the patterns; simulators show the timelines but play no haptics.

Requirements

  • Xcode 26 or later (Swift 6.2 toolchain)
  • iOS 17+ (custom haptics need an iPhone 8 or later) and watchOS 10+

Contributing

Issues and pull requests are welcome. Please run the tests (xcodebuild test -scheme HapticKit -destination 'platform=iOS Simulator,name=iPhone 17') before opening a PR.

License

HapticKit is available under the MIT license. See LICENSE.

About

Expressive haptics for iPhone and Apple Watch: ready-made Core Haptics and WatchKit patterns, a tiny DSL, SwiftUI modifiers and a playground app.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages