Skip to content

Kotlin Multiplatform SDK

The Pulsar Kotlin Multiplatform SDK exposes the same building blocks as the native SDKs — Presets, PatternComposer, and RealtimeComposer — from common Kotlin code. The library targets Android and iOS (iosX64, iosArm64, iosSimulatorArm64) and bridges to the native Pulsar implementation on each platform.

  • Kotlin 2.0+
  • Android API 24+ (Android 7.0) for Android targets
  • iOS 13+ for iOS targets

Latest available version: 0.1.0

Add Pulsar KMP as a Gradle dependency in your shared module:

dependencies {
implementation("com.swmansion:pulsar-kmp:0.1.0")
}

The library publishes the artifact as a Kotlin Multiplatform module — Gradle will resolve the right variant for each target automatically.

Pulsar registers itself on Android through a ContentProvider (PulsarInitializer), so no manual wiring is required.

Declare the vibration permission in your Android app’s AndroidManifest.xml:

<manifest ...>
<uses-permission android:name="android.permission.VIBRATE" />
<application ...>
...
</application>
</manifest>

Without android.permission.VIBRATE, Android will block vibration playback. Pulsar logs a warning and skips the vibration instead of crashing.

The iOS target uses CoreHaptics directly — no extra setup beyond adding the dependency. When consuming the KMP library from an Xcode project, link the produced framework as you would any other Kotlin/Native binary.

Create a Pulsar instance from common code:

import com.swmansion.pulsar.kmp.Pulsar
val pulsar = Pulsar.create()

Pulsar.create() resolves the platform implementation registered for the current target. On Android the factory is registered automatically by PulsarInitializer; on iOS the factory is provided by the iOS target. If you need to register a custom factory (for tests or advanced setups), use Pulsar.registerFactory(...) before the first create() call.


A collection of ready-to-use haptic patterns accessed through pulsar.getPresets().

val presets = pulsar.getPresets()

Each built-in preset is exposed as a direct method on the PulsarPresets instance, mirroring the React Native API.

val presets = pulsar.getPresets()
presets.hammer()
presets.dogBark()
presets.fanfare()

Built-in presets can also be played by passing their name to play(). The method returns true if the preset was found and dispatched, or false otherwise.

fun play(name: String): Boolean

getByName(name) returns a lightweight PulsarPreset handle that can be played later, or null if the name is unknown:

fun getByName(name: String): PulsarPreset?

Available names:

  • Afterglow
  • Aftershock
  • Alarm
  • Anvil
  • Applause
  • Ascent
  • BalloonPop
  • Barrage

Example

val presets = pulsar.getPresets()
presets.play("Hammer")
presets.play("DogBark")

For per-preset descriptions, see the iOS and Android reference pages — the underlying patterns are shared.

PulsarPresets exposes its own preload/cache controls in addition to the equivalents on the Pulsar instance.

MethodDescription
preloadPresetByName(name: String)Preload a single preset by name
preloadPresetByNames(names: List<String>)Preload multiple presets in a batch
enableCache(state: Boolean)Enable or disable preset caching
isCacheEnabled(): BooleanReturns true if caching is currently enabled
resetCache()Drop all cached preset data

Cross-platform system feedback styles are exposed as direct methods so you can call them from common code without name lookup.

MethodDescription
systemImpactHeavy()Heavy impact
systemImpactLight()Light impact
systemImpactMedium()Medium impact
systemImpactRigid()Rigid impact
systemImpactSoft()Soft impact

Example

val presets = pulsar.getPresets()
presets.systemImpactMedium()
presets.systemNotificationSuccess()

Android’s HapticFeedbackConstants and VibrationEffect system styles are also surfaced on the common API. They are no-ops on iOS, so you can call them from common code — but they only produce feedback on Android.

MethodDescription
systemEffectClick()VibrationEffect.EFFECT_CLICK
systemEffectDoubleClick()VibrationEffect.EFFECT_DOUBLE_CLICK
systemEffectTick()VibrationEffect.EFFECT_TICK
systemEffectHeavyClick()VibrationEffect.EFFECT_HEAVY_CLICK
systemLongPress()HapticFeedbackConstants.LONG_PRESS

Composes and plays custom haptic patterns. Get a new instance from pulsar.getPatternComposer(). Each call returns a fresh composer, so you can manage multiple patterns independently.

val composer = pulsar.getPatternComposer()

Parses a PatternData object and prepares it for playback.

fun parsePattern(pattern: PatternData, fromMs: Long = 0)

fromMs starts the pattern that far into its own timeline. The engine can only play a parsed pattern from zero, so Pulsar re-anchors it instead: discrete events before the seek are dropped and the rest rebased, and each continuous envelope is re-anchored on its value at that instant.

Parses a pattern together with a short sound played in sync with the haptics.

fun parsePatternWithSound(pattern: PatternData, sound: SoundData, fromMs: Long = 0)

fromMs seeks the whole preset: the haptics are re-anchored and the sound advances into the file by the same amount, so both move together. It composes with the sound’s own startMs/durationMs trim window rather than replacing it.

On Android, an explicit .ogg with baked haptic channels enables audio-coupled sync on supported devices; any other file — or a bare name, which defaults to .wav — plays the audio while the pattern’s own haptics fire in parallel. On iOS the sound is registered as a Core Haptics audio event on the pattern’s timeline. See SoundData.

Convenience method that parses and immediately plays a pattern.

fun playPattern(pattern: PatternData)

Plays the previously parsed pattern.

fun play()

Plays only the audio simulation of the parsed pattern, skipping the haptic engine. Useful for previewing patterns on simulators.

fun playAudioOnly()

Stops all playback.

fun stop()
val composer = pulsar.getPatternComposer()
val pattern = PatternData(
continuousPattern = ContinuousPattern(
amplitude = listOf(
ValuePoint(time = 0, value = 0f),
ValuePoint(time = 200, value = 1f),
ValuePoint(time = 400, value = 0f),
),
frequency = listOf(
ValuePoint(time = 0, value = 0.3f),
ValuePoint(time = 400, value = 0.8f),
)
),
discretePattern = listOf(
ConfigPoint(time = 0, amplitude = 1f, frequency = 0.5f),
ConfigPoint(time = 100, amplitude = 0.5f, frequency = 0.5f),
)
)
composer.playPattern(pattern)

Provides real-time haptic control with live amplitude and frequency modulation. Useful for gesture-driven or continuously evolving haptic experiences. Get a composer from pulsar.getRealtimeComposer().

val realtime = pulsar.getRealtimeComposer()

On Android, you can also request a composer that uses a specific strategy, or read/write the global default via the realtimeComposerStrategy property on Pulsar. See RealtimeComposerStrategy for available strategies.

val realtime = pulsar.getRealtimeComposer(RealtimeComposerStrategy.PRIMITIVE_COMPLEX)
pulsar.realtimeComposerStrategy = RealtimeComposerStrategy.ENVELOPE

Updates the ongoing haptic with new amplitude and frequency values. Automatically starts playback if it is not already active. Values should be in the 0-1 range.

fun set(amplitude: Float, frequency: Float)

Plays a single discrete haptic event.

fun playDiscrete(amplitude: Float, frequency: Float)

Stops the active continuous haptic.

fun stop()

Returns true if a continuous haptic is currently playing.

fun isActive(): Boolean
val realtime = pulsar.getRealtimeComposer()
realtime.set(amplitude = 0.5f, frequency = 0.8f)
realtime.set(amplitude = 1.0f, frequency = 0.3f)
realtime.playDiscrete(amplitude = 0.7f, frequency = 0.5f)
realtime.stop()

Plays haptics from a cross-platform AdaptivePreset. The correct configuration is selected for the current platform automatically. Each platform can provide either a custom PatternData or a callback that triggers a built-in or system preset directly.

val haptics = pulsar.createAdaptiveHaptics(preset)
sealed class AdaptivePresetConfig {
class Function(val play: (PulsarPresets) -> Unit) : AdaptivePresetConfig()
class Pattern(val pattern: PatternData) : AdaptivePresetConfig()
}
  • Function — a callback that receives the PulsarPresets instance and triggers a native or built-in preset directly.
  • Pattern — a custom PatternData parsed once on creation and replayed on each play().
data class AdaptivePreset(
val ios: AdaptivePresetConfig,
val android: AdaptivePresetConfig,
)

Plays the configuration for the current platform. If the platform config is a Function, it is invoked. If it is a Pattern, it is played via the underlying pattern composer.

Stops the active pattern. No-op when the platform config is a Function.

import com.swmansion.pulsar.kmp.AdaptiveHaptics
import com.swmansion.pulsar.kmp.AdaptivePreset
import com.swmansion.pulsar.kmp.AdaptivePresetConfig
import com.swmansion.pulsar.kmp.ConfigPoint
import com.swmansion.pulsar.kmp.ContinuousPattern
import com.swmansion.pulsar.kmp.PatternData
import com.swmansion.pulsar.kmp.Pulsar
val pulsar = Pulsar.create()
val adaptivePreset = AdaptivePreset(
ios = AdaptivePresetConfig.Function { presets ->
presets.systemNotificationSuccess()
},
android = AdaptivePresetConfig.Pattern(
PatternData(
continuousPattern = ContinuousPattern(amplitude = emptyList(), frequency = emptyList()),
discretePattern = listOf(
ConfigPoint(time = 0, amplitude = 1f, frequency = 0.5f),
ConfigPoint(time = 150, amplitude = 0.6f, frequency = 0.4f),
),
),
),
)
val haptics = pulsar.createAdaptiveHaptics(adaptivePreset)
haptics.play()

A .pulsar bundle exported from Pulsar Studio holds your own presets — each a haptic pattern with optional synced audio and animation. pulsar-gen turns a bundle into a typed accessor, so every preset is a real member: a renamed preset becomes a compile error rather than a silent runtime miss.

KMP ships the bundle API under com.swmansion.pulsar.kmp.bundle, so point the generator at that runtime package:

Terminal window
npx pulsar-gen acme-pack.pulsar --target kotlin \
--package com.example.app.bundles \
--runtime-package com.swmansion.pulsar.kmp.bundle \
--out composeApp/src/commonMain/kotlin/com/example/app/bundles/

KMP has no shared asset API, so the app supplies the bytes. With Compose Resources, put the bundle in commonMain/composeResources/files/ and read it in a LaunchedEffect:

val pulsar = Pulsar.create()
var bundle by remember { mutableStateOf<AcmePack.Presets?>(null) }
LaunchedEffect(Unit) {
val bytes = Res.readBytes("files/acme-pack.pulsar")
bundle = pulsar.loadBundleSync(AcmePack.descriptor, bytes)
}
bundle?.heartbeatV2?.play()

loadBundleAsync is the suspend equivalent, decoding on Dispatchers.Default:

bundle = pulsar.loadBundleAsync(AcmePack.descriptor, bytes)

If the .pulsar ships as a platform asset — src/androidMain/assets/ on Android, the app bundle on iOS — the descriptor resolves it by name and no byte-reading is needed:

val bundle = pulsar.loadBundleSync(AcmePack.descriptor)

Compose Resources stores files under its own directory rather than the platform asset location, so a bundle kept there uses the bytes overload above.

The loaded bundle’s content hash is checked against the generated types; pass strict = false to skip it.

Each PresetHandle exposes id, name, duration, pattern, hasAudio, hasAnimation, play(fromMs = 0), stop(), and animation — the Lottie bytes and timing for your own animation view.

Pass fromMs to start a preset that far into its own timeline — audio and haptics seek together, so a progress bar can scrub it:

bundle.heartbeatV2.play(fromMs = 2500)

Every non-zero seek re-parses the preset; playing from the start reuses the cached parse.

The Lottie SDK takes a PresetHandle directly and reads all of that for you — pattern, animation and duration — so you rarely have to unpack it by hand.

See kmp/PulsarApp for a working screen.


Configuration methods available directly on the Pulsar instance.

MethodDescription
enableHaptics(state: Boolean)Enable or disable all haptic feedback
enableSound(state: Boolean)Enable or disable audio simulation
enableCache(state: Boolean)Enable or disable preset caching
isCacheEnabled()Returns true if preset caching is enabled
clearCache()Clear the preset cache
preloadPresets(presetNames: List<String>)Preload presets by name for faster playback
stopHaptics()Stop all currently playing haptics
shutDownEngine()Tear down the underlying haptic engine
isHapticsEnabled()Returns true if haptics are currently enabled
isHapticsSupported()Returns true if the device supports haptics
canPlayHaptics()Returns true when haptics can play right now
hapticSupport(): CompatibilityModeReturns the device’s CompatibilityMode
hapticCapabilities(): HapticCapabilitiesReturns the HapticCapabilities behind that level
forceHapticsSupportLevel(mode: CompatibilityMode)(Android only) Override the detected support level. Intended for testing fallback behavior.
enableImpulseCompositionMode(state: Boolean)(Android only) Enable or disable impulse composition mode
realtimeComposerStrategy (property)(Android only) Get or set the default RealtimeComposerStrategy
val pulsar = Pulsar.create()
pulsar.preloadPresets(listOf("Hammer", "DogBark"))
if (pulsar.canPlayHaptics()) {
pulsar.getPresets().play("Hammer")
}
pulsar.enableHaptics(false)

All shared types live in the com.swmansion.pulsar.kmp package and are usable from common code.

Describes a complete haptic pattern with discrete pulses and continuous envelope curves.

data class PatternData(
val continuousPattern: ContinuousPattern,
val discretePattern: List<ConfigPoint>
)

A secondary constructor accepts the raw triplet form (List<List<List<Float>>> for continuous, List<List<Float>> for discrete) for parity with serialized pattern definitions.

A short sound to play in sync with a haptic pattern via parsePatternWithSound.

data class SoundData(
val uri: String, // File path, file:// uri, or bundled resource name
val volume: Float = 1f, // Playback volume (0-1)
val offset: Long = 0L, // Audio delay relative to haptics, in ms
val startMs: Long = 0L, // Where playback begins in the source file, in ms
val durationMs: Long = 0L // How much to play from startMs, in ms (0 = to the end)
)

The format comes from uri’s extension; when none is given the default is .wav, so a bare name like "beep" resolves beep.wav on both platforms (iOS app bundle / Android res/raw).

startMs and durationMs trim the clip: startMs is where playback begins in the source file and durationMs is how much of it to play (0 plays to the end). The audio is sliced to that window before playing rather than seeked at runtime, so one bundled file can back several trimmed sounds without losing sync.

Represents continuous haptic curves for amplitude and frequency.

data class ContinuousPattern(
val amplitude: List<ValuePoint>,
val frequency: List<ValuePoint>
)

A single point in a continuous curve.

data class ValuePoint(
val time: Long, // Milliseconds from pattern start
val value: Float // Normalized value (0-1)
)

A single discrete haptic event.

data class ConfigPoint(
val time: Long, // Milliseconds from pattern start
val amplitude: Float, // Intensity (0-1)
val frequency: Float // Sharpness (0-1)
)

Use discretePattern for distinct taps and impacts. Use continuousPattern envelopes to shape a sustained haptic over time.

The haptic capability level of the current device, returned by pulsar.hapticSupport().

enum class CompatibilityMode {
NO_SUPPORT,
LIMITED_SUPPORT,
STANDARD_SUPPORT,
ADVANCED_SUPPORT,
}

On Android, pulsar.forceHapticsSupportLevel(mode) accepts these values to override the detected fallback path.

Example

val pulsar = Pulsar.create()
if (pulsar.hapticSupport() >= CompatibilityMode.STANDARD_SUPPORT) {
pulsar.getPresets().dogBark()
}

The individual platform capabilities behind CompatibilityMode, returned by pulsar.hapticCapabilities().

data class HapticCapabilities(
val hasAmplitudeControl: Boolean,
val hasPrimitiveSupport: Boolean,
val isEnvelopeSupported: Boolean,
val isFrequencyProfileSupported: Boolean,
val minControlPointDurationMillis: Long,
)

On Android each field maps to the corresponding Vibrator query. On iOS, Core Haptics exposes a single capability and the Taptic Engine renders the full event set uniformly, so hasAmplitudeControl, hasPrimitiveSupport and isEnvelopeSupported all mirror haptic support; isFrequencyProfileSupported is false and minControlPointDurationMillis is 0.

The support level is decided by amplitude control (and, on Android 16+, the frequency profile). Primitive support is not part of it, so a standard-support device can still lack composition primitives. If you render your own single-hit patterns, check hasPrimitiveSupport and fall back to a waveform rather than composing primitives the vibrator will silently drop.

Example

val pulsar = Pulsar.create()
if (!pulsar.hapticCapabilities().hasPrimitiveSupport) {
pulsar.enableImpulseCompositionMode(false)
}

Android-only. Controls how RealtimeComposer simulates continuous haptics, since Android has no native continuous haptic API. Pass one of these values to pulsar.getRealtimeComposer(strategy) or assign to pulsar.realtimeComposerStrategy.

enum class RealtimeComposerStrategy {
ENVELOPE,
PRIMITIVE_TICK,
PRIMITIVE_COMPLEX,
ENVELOPE_WITH_DISCRETE_PRIMITIVES,
}
ValueDescription
ENVELOPEApproximation based on the Envelope API. Allows control over amplitude and frequency, but the signal oscillates and can be unstable. Available on Android API 36+.
PRIMITIVE_TICKApproximation using the Composition API TICK primitive at varying intervals. Amplitude is controllable; frequency is simulated by the timing between ticks. Signal is discrete rather than continuous.
PRIMITIVE_COMPLEXSimilar to PRIMITIVE_TICK, but uses multiple primitives depending on the requested frequency.
ENVELOPE_WITH_DISCRETE_PRIMITIVESDefault. Hybrid strategy. Uses the Envelope API for continuous events (API 36+) and composition primitives for discrete events (API 33+). Best of both worlds when both event types are used.

Used internally by curve interpolation and exposed for advanced custom composers.

data class ControlPoint(
val intensity: Float,
val sharpness: Float,
val duration: Long
)

For tests or hosting environments where the default initializer cannot run, register a custom factory before the first Pulsar.create() call:

import com.swmansion.pulsar.kmp.Pulsar
import com.swmansion.pulsar.kmp.PulsarPlatformFactory
Pulsar.registerFactory(myFactory)
val pulsar = Pulsar.create()

PulsarPlatformFactory produces a PulsarPlatformHandle, which exposes the low-level operations (presets(), patternComposer(), realtimeComposer(), lifecycle, capability checks) that the public API delegates to.