← Blog
kotlin-multiplatformcompose-multiplatformandroidioshaptics

Cross-Platform Haptics in Kotlin Multiplatform: One Interface, Two Engines

April 1, 2026

Haptic feedback is one of those details that separates a good app from a great one. A light tap when you drag a slider, a stronger pulse when your answer locks in — users notice when it's missing even if they can't name what's wrong. In a Kotlin Multiplatform project, implementing haptics cleanly is non-trivial: Android and iOS each have their own APIs, their own mental models, and their own platform requirements.

In PickPerfect — a color-matching game built entirely with Compose Multiplatform — every interactive element fires haptic feedback from shared commonMain code. Here's exactly how that works.

The interface: two functions, no platform leak

The entire haptic contract lives in a single file under commonMain:

// commonMain/haptics/Haptics.kt
 
/** Light tap — fires on every interactive element. */
expect fun hapticTick()
 
/** Stronger confirmation pulse — fires on submit / result reveal. */
expect fun hapticConfirm()

That's it. Two expect functions. No interfaces, no classes, no dependency injection. Shared UI code imports this file and calls these functions as if they were plain Kotlin — because from commonMain's perspective, they are.

The expect keyword is Kotlin's compile-time contract: every target in the build must supply a corresponding actual implementation. If you forget to write one, the build fails. It's stronger than an interface because the enforcement happens at compile time, not at runtime when a missing implementation would crash the user.

The iOS side: beautifully straightforward

iOS gives you a clean, high-level haptics API through UIKit. The implementation is four lines:

// iosMain/haptics/Haptics.ios.kt
 
actual fun hapticTick() {
    UIImpactFeedbackGenerator(UIImpactFeedbackStyle.UIImpactFeedbackStyleLight)
        .impactOccurred()
}
 
actual fun hapticConfirm() {
    UINotificationFeedbackGenerator()
        .notificationOccurred(UINotificationFeedbackType.UINotificationFeedbackTypeSuccess)
}

UIImpactFeedbackGenerator maps to the physical sensation of pressing something — a crisp, short tap. UINotificationFeedbackGenerator with .success is heavier and more deliberate, the right feel for a result being revealed. Both are stateless: create, fire, done. No context, no lifecycle, no setup required.

The Android side: API level archaeology

Android's haptics API has evolved across three distinct eras, and a production app needs to handle all of them:

// androidMain/haptics/Haptics.android.kt
 
actual fun hapticTick() {
    if (!::appContext.isInitialized) return
    val v = vibrator(appContext)
    when {
        Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> // API 29+
            v.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK))
        Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> // API 26–28
            v.vibrate(VibrationEffect.createOneShot(18, 80))
        else -> {                                          // API < 26
            @Suppress("DEPRECATION")
            v.vibrate(18)
        }
    }
}
 
actual fun hapticConfirm() {
    if (!::appContext.isInitialized) return
    val v = vibrator(appContext)
    when {
        Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ->
            v.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_HEAVY_CLICK))
        Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ->
            v.vibrate(VibrationEffect.createOneShot(40, 120))
        else -> {
            @Suppress("DEPRECATION")
            v.vibrate(40)
        }
    }
}

API 29+ (Android 10): createPredefined() with EFFECT_CLICK or EFFECT_HEAVY_CLICK. These are semantic effects — the OS decides the waveform based on the device's actuator. A Pixel and a Galaxy will feel different from each other, but both feel intentional and native. This is the right path whenever available.

API 26–28 (Android 8–9): VibrationEffect exists but predefined effects don't. createOneShot(durationMs, amplitude) gives you manual control. Duration 18ms / amplitude 80 for a tick; 40ms / amplitude 120 for a confirmation pulse. These values were tuned on real hardware — not derived from documentation.

Below API 26: The deprecated vibrate(ms) overload — no amplitude control, duration only. At this point you're doing the minimum to not break on old devices.

The if (!::appContext.isInitialized) return guard is a deliberate safety net. If haptics fire before initialization — in a Compose preview, for example — nothing crashes. It silently skips.

The Android context problem

Unlike iOS, Android's Vibrator requires a Context. Since commonMain functions can't accept platform types as parameters without leaking Android APIs into shared code, the solution is a one-time initialization call:

// androidMain/haptics/Haptics.android.kt
 
private lateinit var appContext: Context
 
fun initHaptics(context: Context) {
    appContext = context.applicationContext
}
// androidMain/MainActivity.kt
 
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    initHaptics(this)   // called once, before any UI
    setContent { App() }
}

Note context.applicationContext — storing an Activity reference here would leak it for the lifetime of the module. Application context is safe because the Vibrator service outlives any single Activity.

This init* pattern is common in KMP projects where a platform dependency needs to be wired before shared code runs. It's not the only option — Koin or kotlin-inject can provide a platform-specific service via DI — but for something this contained, a module-level lateinit var keeps the code flat and easy to follow.

Calling it from shared Compose code

With platform detail fully encapsulated, call sites in commonMain are completely clean:

// commonMain/ui/screens/GameScreen.kt
 
// Light tap on back navigation
Box(
    modifier = Modifier.clickable(remember { MutableInteractionSource() }, null) {
        hapticTick()
        onBack()
    }
)
 
// Confirmation pulse on submit
Box(
    modifier = Modifier.clickable(remember { MutableInteractionSource() }, null) {
        hapticConfirm()
        onSubmit()
    }
)

No LocalContext.current, no platform check, no abstraction layer to unwrap. The shared UI doesn't know — or care — whether it's running on Android or iOS.

The same pattern applies in ColorPickerComponent, where hapticTick() fires as a slider crosses discrete hue steps, reinforcing the sense of snapping to a value.

Tradeoffs and what this pattern doesn't cover

This approach works well for simple, stateless haptic events. A few limitations worth naming:

No composable-scoped haptics. These are top-level functions, not @Composable. You can't use LocalHapticFeedback from Compose (which is Android-only anyway). If you need haptics tied to remember state or LaunchedEffect, you'd need to refactor into a provided composable service.

Android amplitude variance. Below API 29, you're estimating with raw duration/amplitude values. OEM actuator hardware varies enormously — "amplitude 80" feels completely different on a budget phone versus a flagship. The predefined semantic effects on API 29+ sidestep this entirely.

Desktop is a no-op stub. If you later target jvmMain, you'll need a third actual implementation — or an empty one. The pattern extends cleanly; the compiler will remind you if you forget.

No user preference toggle. Adding a "disable haptics" setting would mean threading a boolean into this module. The cleanest approach: check the preference inside the actual implementations, so commonMain call sites stay identical.

Summary

iOSAndroid
APIUIKit feedback generatorsVibrator / VibrationEffect
Context neededNoYes — applicationContext
API branchingNone3 paths (< 26, 26–28, 29+)
Lines of implementation4~35
Call site complexityNoneNone

The expect/actual mechanism draws a clean line: platform complexity is fully contained in androidMain and iosMain, shared Compose code calls plain functions, and the compiler guarantees every platform is covered. The call sites don't branch, don't import platform types, and don't need to change when you add a new target.

For a project where haptics fire on every single interaction, that boundary is worth keeping clean.


PickPerfect is built with Compose Multiplatform. The full source structure follows the standard KMP template from the JetBrains KMP wizard.

← All postshello@zarzara.app