Kotlin Multiplatform
pulsar-kmp-lottie plays a Pulsar haptic pattern locked to a Lottie animation from common Compose Multiplatform code, on Android and iOS targets.
HapticLottie renders through compottie and takes the same LottieComposition you already hand to rememberLottiePainter, so it drops into an existing screen. If you render with something else, HapticLottieSync follows a progress value you feed it and draws nothing.
Read the Lottie SDK overview for the engine modes and timing rules this page assumes.
Requirements
Section titled “Requirements”- Kotlin 2.0+
- Android API 24+ (Android 7.0) for Android targets, iOS 13+ for iOS targets
- The Pulsar KMP SDK (
com.swmansion:pulsar-kmp), pulled in automatically - compottie, pulled in automatically — only
HapticLottieSyncworks without it
Installation
Section titled “Installation”Latest available version: 0.1.0
Add the dependency to your shared module:
commonMain.dependencies { implementation("com.swmansion:pulsar-kmp-lottie:0.1.0")}The library publishes a Kotlin Multiplatform module, so Gradle resolves the right variant per target. Add your Lottie renderer separately — this package has no opinion about which one.
The Android target needs the vibration permission in your app’s AndroidManifest.xml:
<manifest ...> <uses-permission android:name="android.permission.VIBRATE" />
<application ...> ... </application></manifest>HapticLottie
Section titled “HapticLottie”Renders a Lottie animation and plays a pattern locked to its timeline. It takes the same
LottieComposition compottie’s own rememberLottiePainter takes, so an existing screen only
swaps its Image:
import com.swmansion.pulsar.lottie.HapticLottieimport io.github.alexzhirkevich.compottie.LottieCompositionSpecimport io.github.alexzhirkevich.compottie.rememberLottieComposition
@Composablefun Success(pattern: PatternData) { val composition by rememberLottieComposition { LottieCompositionSpec.JsonString(json) }
HapticLottie(composition, haptics = pattern, modifier = Modifier.size(200.dp))}From a bundle preset
Section titled “From a bundle preset”A preset in a .pulsar bundle already pairs an animation with the pattern its author aligned to it. Pass the preset and it loads the composition for you — no rememberLottieComposition, no haptics:
val bundle = pulsar.loadBundleSync(AcmePack.descriptor)
HapticLottie(preset = bundle.celebration, modifier = Modifier.size(200.dp))The preset supplies the animation, haptics from its pattern, and durationMs from its authored length — each still overridable on its own. See bundle presets.
Parameters
Section titled “Parameters”Both overloads take the same arguments; the first is composition: LottieComposition?, the second preset: PresetHandle.
| Parameter | Type | Default | Description |
|---|---|---|---|
composition | LottieComposition? | – | The animation to render, from rememberLottieComposition. |
preset | PresetHandle? | null | Bundle preset supplying the pattern and authored duration — and, in the preset overload, the animation. |
modifier | Modifier | Modifier | Applied to the rendered animation. |
haptics | PatternData? | null | Pattern to sync. Overrides the preset’s pattern; omit both to render without haptics. |
hapticMode | HapticMode? | derived | Engine mode — see Engine modes. |
hapticOffset | Long | 0 | Shift the haptics by ±ms relative to the animation. |
hapticsEnabled | Boolean | true | Turn haptics off without touching the animation. |
durationMs | Long | 0 | Clock length in ms. 0 derives it from the preset, the composition, then the pattern. |
isPlaying | Boolean | true | Whether the animation runs. A false → true transition starts the haptics. |
iterations | Int | 1 | Loop count. Pass Compottie.IterateForever to loop. |
contentDescription | String? | null | Accessibility label for the rendered animation. |
alignment / contentScale | Center / Fit | Forwarded to the underlying Image. | |
pulsar | Pulsar | Pulsar.create() | Provide your own platform-initialized instance. |
The preset overload renders an empty Box when the preset carries no animation.
HapticLottieSync
Section titled “HapticLottieSync”Renders nothing and follows a progress value you supply — for a renderer other than compottie, or to add haptics to an existing screen without touching how it draws:
val composition by rememberLottieComposition { LottieCompositionSpec.JsonString(json) }val progress by animateLottieCompositionAsState(composition, isPlaying = playing)
Image(rememberLottiePainter(composition, progress = { progress }), contentDescription = null)
HapticLottieSync( progress = progress, isPlaying = playing, haptics = pattern, durationMs = composition?.duration?.inWholeMilliseconds ?: 0,)It takes progress and isPlaying in place of composition, plus the same haptic arguments as HapticLottie. durationMs here is the animation’s own length; a preset’s authored duration takes precedence, and 0 falls back to that, then to the pattern’s length.
The engine is rebuilt whenever pulsar, preset, haptics, hapticMode, hapticOffset or hapticsEnabled changes, and stopped when the composable leaves the composition. Hoist those values (remember the pattern) rather than constructing them inline.
HapticLottieEngine
Section titled “HapticLottieEngine”The pure, framework-agnostic engine behind both composables. Use it directly when the progress comes from somewhere Compose is not involved in:
val engine = HapticLottieEngine( pulsar = pulsar, preset = pack.celebration, // optional — supplies the pattern and duration haptics = pattern, // optional — overrides the preset's pattern hapticMode = HapticMode.REALTIME, hapticOffset = 0, hapticsEnabled = true, durationMs = null,)
engine.setPlaying(true)engine.onProgress(progress, durationMs) // every frameengine.stop()| Member | Description |
|---|---|
setPlaying(isPlaying: Boolean) | Notify a play/pause transition. In PATTERN mode a true transition fires the buffered pattern. |
onProgress(progress: Float, durationMs: Long = 0) | Feed the current position. 0 uses the duration the engine resolved. |
stop() | Stop the haptics and reset the discrete-event window. |
resolvedDurationMs | The clock length the engine resolved. |
onProgress handles a wrapped playhead: when the new time is behind the previous one — a loop or a backwards seek — the discrete window restarts from zero, so looping re-fires the events rather than swallowing them.
Engine modes
Section titled “Engine modes”enum class HapticMode { REALTIME, PATTERN }REALTIME(default) — the animation progress is the master clock and the pattern is sampled intoRealtimeComposer.setandplayDiscreteon every progress update. Honours pause, seek and loop. On Android the vibrator is re-driven per update, so continuous fidelity is coarser there than on iOS.PATTERN— the pre-parsed pattern plays whole throughPatternComposer, aligned to the start, which gives the best native fidelity at the cost of seeking.
Realtime fidelity tracks how often progress changes, so it follows your renderer’s frame rate. KMP has no bundle audio yet, so the audio-preset exception described in the overview never fires here.
See Choosing between them for the trade-offs.
Reading a preset yourself
Section titled “Reading a preset yourself”animationJson() hands a preset’s Lottie to any renderer as a JSON string, and returns null when it carries no animation:
import com.swmansion.pulsar.lottie.animationJson
val composition by rememberLottieComposition { LottieCompositionSpec.JsonString(preset.animationJson()!!)}| Member | Type | Description |
|---|---|---|
id / name | String | Code-safe id, and the human label it was authored under. |
duration | Long | Authored length in ms, or 0 when the bundle carries no hint. |
pattern | PatternData | The authored pattern — what REALTIME samples. |
animation | BundleAnimation? | Lottie data, frameRate and totalFrames. |
hasAudio / hasAnimation | Boolean | What the preset was authored with. hasAudio is always false on KMP. |
play() / stop() | – | Play the preset natively. |
See the KMP SDK page for how bundles are loaded.