← Back to list

How PickPerfect calls hapticTick() from shared Compose code while each platform does its own thing…

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…

Zarzara · 2026-04-01 09:36 · 4 claps · 4.8 min read
#android #ios #haptic-feedback #kotlin-multiplatform #compose-multiplatform
Open on Medium ↗
Wiki topics: RAG · RAG & Retrieval 📱 · Mobile Development

How PickPerfect calls hapticTick() from shared Compose code while each platform does its own thing under the hood.

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 articulate why. In a Kotlin Multiplatform (KMP) 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, and why this pattern scales.

Follow Zarzara for more posts on Kotlin Multiplatform, Compose, and mobile craft. (This article is also available on zarzara.app).

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 framework. 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 actualimplementation. 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 a real user.

The iOS Side: Beautifully Straightforward

iOS gives you a clean, high-level haptics API through UIKit. The full 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 — crisp, short, deliberate. UINotificationFeedbackGenerator with .success is heavier and more intentional, the right feel for a result being revealed. Both are stateless: create, fire, done. No context, no lifecycle, no setup required. iOS haptics are an example of Apple getting an API right.

The Android Side: API Level Archaeology

Android’s haptics API has evolved across three distinct eras, and a production app targeting a wide device range 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 ->
            v.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK))
        Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ->
            v.vibrate(VibrationEffect.createOneShot(18, 80))
        else -> {
            @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 semantic effect constants. The OS decides the exact waveform based on the device's actuator hardware. A Pixel and a Galaxy will feel physically different — but both feel intentional and native. This is the right path whenever it's available, and it's available on the vast majority of active Android devices today.
  • 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. These values were tuned on real hardware across multiple devices — 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 — which is still the right call if you care about your full install base.

The if (!::appContext.isInitialized) return guard is intentional. If haptics fire before initialization — in a Compose preview, a unit test, or an unexpected early call — 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 at app startup:

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

context.applicationContext matters here — storing an Activity reference in a module-level variable would leak it for the lifetime of the module. Application context is safe because the Vibrator service outlives any single screen.

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

Calling it From Shared Compose Code

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

Kotlin

// 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 if (platform == Android). No abstraction layer to unwrap. The shared UI is completely oblivious to the platform it's running on — and that's exactly the point.

The same pattern applies in ColorPickerComponent, where hapticTick() fires each time a slider crosses a discrete hue step, reinforcing the snapping sensation that makes the interaction feel precise rather than smooth and slippery.

Why Not LocalHapticFeedback?

A reasonable question. LocalHapticFeedback.current is a Compose API, but it lives in androidx.compose.ui — which is Android-only. It can't be imported in commonMain, so using it from shared code would break the iOS build at the import level, not at runtime.

Beyond the platform restriction, it only exposes two feedback types: LongPress and TextHandleMove. That's not enough granularity for a game that needs to distinguish a subtle slider tick from a deliberate result confirmation. The expect/actualpattern gives you full control over the physical sensation on each platform.

Tradeoffs and What This Pattern Doesn’t Cover

No pattern is free of tradeoffs. These are worth knowing before you adopt this one:

  • No composable-scoped haptics: These are top-level functions, not @Composable. If you need haptic feedback tied to remember state or triggered inside a LaunchedEffect, you'd need to refactor this into a composable-provided service.
  • Android amplitude variance: Below API 29, raw duration/amplitude values produce wildly different sensations across OEM hardware. An “amplitude 80” on a cheap phone is not the same physical experience as on a flagship. The predefined semantic effects on API 29+ eliminate this variance entirely — another reason to prefer them.
  • Desktop is a no-op stub: Targeting jvmMain later? You'll need a third actual — or an empty one. The compiler will remind you if you forget. The pattern scales cleanly.
  • No user preference toggle: Adding a “disable haptics” setting requires threading a boolean through this module. The cleanest implementation: check the preference inside each actual, so commonMain call sites never change.

Summary

[embed]

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 a color memory game built with Compose Multiplatform.*


메타데이터
post_id
fbe8d2a09b65
slug
how-pickperfect-calls-haptictick-from-shared-compose-code-while-each-platform-does-its-own-thing-fbe8d2a09b65
url
https://medium.com/@zarzara/how-pickperfect-calls-haptictick-from-shared-compose-code-while-each-platform-does-its-own-thing-fbe8d2a09b65
canonical_url
https://medium.com/@zarzara/how-pickperfect-calls-haptictick-from-shared-compose-code-while-each-platform-does-its-own-thing-fbe8d2a09b65
author_url
https://medium.com/@zarzara
status
ok
fetched_at
2026-08-09 23:29:40