Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Jindong: Introducing Declarative Haptics in Com...

Jindong: Introducing Declarative Haptics in Compose Multiplatform

This is the speaker deck for Jindong: Introducing Declarative Haptics in Compose Multiplatform, presented at COSCUP X UbuCon Asia 2026 (Taipei, Taiwan).

Avatar for HyunWoo Lee

HyunWoo Lee

August 08, 2026

More Decks by HyunWoo Lee

Other Decks in Programming

Transcript

  1. JINDONG · 진동 · 振動 Jindong(진동, 振動) Introducing Declarative Haptics

    in Compose Multiplatform 2026 COSCUP × UBUCON ASIA, TAIWAN HyunWoo Lee
  2. HELLO I’m HyunWoo Lee @l2hyunwoo Android Engineer at Toss, South

    Korea’s #1 finance app Community Driven Developer — GDG Korea Android, KUG Seoul, CodeRabbit User Groups Korea Open source Lover, mostly Kotlin Multiplatform (Jindong, Cloudy) and React Native (react-native-nitro-{device-info | cookies}) ❤ Guitar and the gym You can see speaker deck via this link 2026 COSCUP × UBUCON ASIA, TAIWAN 02
  3. AGENDA 01 What Jindong does 02 Why we built it

    03 Inside Jindong 2026 COSCUP × UBUCON ASIA, TAIWAN 03
  4. Jindong.android.kt val vibrator = if (Build.VERSION.SDK_INT 31) { context.getSystemService(VibratorManager class.java)

    defaultVibrator } else { context.getSystemService(Vibrator class.java) } val timings = longArrayOf(100L, 50L, 50L) val amplitudes = if (vibrator hasAmplitudeControl() true) { intArrayOf(128, 0, 191) 0.5 → 128, 0.75 → 191 } else { intArrayOf(255, 0, 255) ERM: any non-zero amplitude rounds up to full } vibrator vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1)) . ? = = : : = > : : 05 . ? / / / / . ? 2026 COSCUP × UBUCON ASIA, TAIWAN
  5. Jindong.android.kt val vibrator = if (Build.VERSION.SDK_INT 31) { context.getSystemService(VibratorManager class.java)

    defaultVibrator } else { context.getSystemService(Vibrator class.java) } val timings = longArrayOf(100L, 50L, 50L) val amplitudes = if (vibrator hasAmplitudeControl() true) { intArrayOf(128, 0, 191) 0.5 → 128, 0.75 → 191 } else { intArrayOf(255, 0, 255) ERM: any non-zero amplitude rounds up to full } vibrator vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1)) . ? = = : : = > : : 06 . ? / / / / . ? 2026 COSCUP × UBUCON ASIA, TAIWAN
  6. Jindong.android.kt val vibrator = if (Build.VERSION.SDK_INT 31) { context.getSystemService(VibratorManager class.java)

    defaultVibrator } else { context.getSystemService(Vibrator class.java) } val timings = longArrayOf(100L, 50L, 50L) val amplitudes = if (vibrator hasAmplitudeControl() true) { intArrayOf(128, 0, 191) 0.5 → 128, 0.75 → 191 } else { intArrayOf(255, 0, 255) ERM: any non-zero amplitude rounds up to full } vibrator vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1)) . ? = = : : = > : : 07 . ? / / / / . ? 2026 COSCUP × UBUCON ASIA, TAIWAN
  7. Jindong.swift let engine = try CHHapticEngine() try engine.start() let events

    = [ CHHapticEvent(eventType: .hapticContinuous, parameters: [ .init(parameterID: .hapticIntensity, value: 0.5), ], relativeTime: 0.0, duration: 0.1), ] CHHapticEvent(eventType: .hapticContinuous, parameters: [ .init(parameterID: .hapticIntensity, value: 0.75), ], relativeTime: 0.15, duration: 0.05), 0.1 + 0.05 = 0.15 let player = try engine.makePlayer(with: CHHapticPattern(events: events, parameters: [])) try player.start(atTime: CHHapticTimeImmediate) / 08 / 2026 COSCUP × UBUCON ASIA, TAIWAN
  8. Jindong.swift let engine = try CHHapticEngine() try engine.start() let events

    = [ CHHapticEvent(eventType: .hapticContinuous, parameters: [ .init(parameterID: .hapticIntensity, value: 0.5), ], relativeTime: 0.0, duration: 0.1), ] CHHapticEvent(eventType: .hapticContinuous, parameters: [ .init(parameterID: .hapticIntensity, value: 0.75), ], relativeTime: 0.15, duration: 0.05), 0.1 + 0.05 = 0.15 let player = try engine.makePlayer(with: CHHapticPattern(events: events, parameters: [])) try player.start(atTime: CHHapticTimeImmediate) / 09 / 2026 COSCUP × UBUCON ASIA, TAIWAN
  9. Jindong.swift let engine = try CHHapticEngine() try engine.start() let events

    = [ CHHapticEvent(eventType: .hapticContinuous, parameters: [ .init(parameterID: .hapticIntensity, value: 0.5), ], relativeTime: 0.0, duration: 0.1), ] CHHapticEvent(eventType: .hapticContinuous, parameters: [ .init(parameterID: .hapticIntensity, value: 0.75), ], relativeTime: 0.15, duration: 0.05), 0.1 + 0.05 = 0.15 let player = try engine.makePlayer(with: CHHapticPattern(events: events, parameters: [])) try player.start(atTime: CHHapticTimeImmediate) / 10 / 2026 COSCUP × UBUCON ASIA, TAIWAN
  10. Imperative vs Declarative Imperative Declarative Describes how to do it.

    Describes what to achieve. • Explicitly defines every step required to complete the task • Includes operations such as data assignment, calculations, loops and conditionals • Abstracts the way a computer executes instructions • Abstracts the logical structure required to complete the task • Expresses conceptual relationships through functions and types • Focuses on business rules, data relationships and state transitions 2026 COSCUP × UBUCON ASIA, TAIWAN 13
  11. If sound can be Composable? Why not vibration? • At

    DroidKaigi(Android Conference, Japan), I saw a project that represented sound using Composable functions. • If sound can be represented this way, could vibration be represented in the same way? 2026 COSCUP × UBUCON ASIA, TAIWAN 15
  12. Things to consider before building an open-source library • Check

    whether your idea already exists • There were already several haptics libraries for Android and Compose Multiplatform, but none were built on Compose Runtime or offered a declarative API. • (Optional) Find a good collaborator • Working alone can make the API design and project vision too one-sided. • A good collaborator can bring a different perspective and help keep the project balanced. 2026 COSCUP × UBUCON ASIA, TAIWAN 16
  13. From Compose-Based to Kotlin-Driven by feedback • Received feedback after

    announcing the first stable release on Slack • Developers wanted a pure Kotlin DSL, rather than only a Compose Runtime–based API • Reconsidered the original design • Compose Runtime was initially chosen to reduce the learning curve • However, a Kotlin DSL could provide a similarly simple and expressive API • Restructured the library • Made the Kotlin-based API the core implementation • Expanded support to developers who do not use Compose or Compose Multiplatform 2026 COSCUP × UBUCON ASIA, TAIWAN 17
  14. Representing Haptic Patterns As Trees fun Haptic() { Haptic(50.ms) Delay(20.ms)

    Haptic(50.ms) Delay(20.ms) Haptic(50.ms) Delay(20.ms) Haptic(100.ms) Delay(20.ms) Haptic(100.ms) Delay(20.ms) // … } 2026 COSCUP × UBUCON ASIA, TAIWAN 20
  15. Representing Haptic Patterns As Trees fun Haptic() { ShortHatpic() ShortHatpic()

    ShortHatpic() LongHaptic() // … } fun ShortHaptic() { Haptic(50.ms) Delay(20.ms) } fun LongHaptic() { Haptic(100.ms) Delay(20.ms) } 2026 COSCUP × UBUCON ASIA, TAIWAN 21
  16. Representing Haptic Patterns As Trees fun Haptic() { Repeat(3) {

    ShortHatpic() } Repeat(3) { LongHaptic() } Repeat(3) { ShortHatpic() } } fun ShortHaptic() { Haptic(50.ms) Delay(20.ms) } fun LongHaptic() { Haptic(100.ms) Delay(20.ms) } 2026 COSCUP × UBUCON ASIA, TAIWAN 22
  17. Representing Haptic Patterns As Trees fun Haptic() { Repeat(3) {

    ShortHatpic() } Repeat(3) { LongHaptic() } Repeat(3) { ShortHatpic() } } fun ShortHaptic() { Haptic(50.ms) Delay(20.ms) } fun LongHaptic() { Haptic(100.ms) Delay(20.ms) } 2026 COSCUP × UBUCON ASIA, TAIWAN 23
  18. jindong Jindong-core • Pure Kotlin DSL for defining haptic patterns

    • Built around HapticElement • Easily extensible with custom haptic effects jindong-compose • Compose Runtime–based API • Define haptic patterns like Composable functions 2026 COSCUP × UBUCON ASIA, TAIWAN 24
  19. HapticElement Basic component of haptic pattern interface HapticElement { val

    children: MutableList<HapticElement> fun collectEvents(startTimeMs: Long): List<ScheduledHapticEvent> fun totalDurationMs(startTimeMs: Long): Long } 2026 COSCUP × UBUCON ASIA, TAIWAN • children • Child nodes forming haptic pattern • collectEvents • Flattens the trees into a list the platform API can consume • totalDurationMs • Answers “how much time has passed once I’m done” • Parents use it to schedule the next sibling’s start 25
  20. class VibrationElement( val durationMs: Long, val intensity: HapticIntensity, ) :

    HapticElement { override val children = mutableListOf<HapticElement>() override fun collectEvents(startTimeMs: Long) = listOf( ScheduledHapticEvent(startTimeMs, durationMs, intensity) ) override fun totalDurationMs(startTimeMs: Long) = durationMs } 2026 COSCUP × UBUCON ASIA, TAIWAN 27
  21. class VibrationElement( val durationMs: Long, val intensity: HapticIntensity, ) :

    HapticElement { override val children = mutableListOf<HapticElement>() override fun collectEvents(startTimeMs: Long) = listOf( ScheduledHapticEvent(startTimeMs, durationMs, intensity) ) override fun totalDurationMs(startTimeMs: Long) = durationMs } class DelayElement(val durationMs: Long) : HapticElement { override val children = mutableListOf<HapticElement>() override fun collectEvents(startTimeMs: Long) = emptyList<ScheduledHapticEvent>() override fun totalDurationMs(startTimeMs: Long) = durationMs } 2026 COSCUP × UBUCON ASIA, TAIWAN 28
  22. class SequenceElement : HapticElement { override val children = mutableListOf<HapticElement>()

    override fun collectEvents(startTimeMs: Long): List<ScheduledHapticEvent> { val (_, events) = children.fold( TimingState(currentTimeMs = startTimeMs, events = emptyList()) ) { state, child -> val childEvents = child.collectEvents(state.currentTimeMs) val nextStartTime = state.currentTimeMs + child.totalDurationMs(state.currentTimeMs) TimingState(nextStartTime, state.events + childEvents) } return events } } 2026 COSCUP × UBUCON ASIA, TAIWAN 29
  23. @HapticDslMarker class HapticPatternScope internal constructor() { internal val rootElement =

    SequenceElement() fun haptic(duration: Duration, intensity: HapticIntensity = MEDIUM) { rootElement.children.add( VibrationElement(duration.inWholeMilliseconds, intensity) ) } fun delay(duration: Duration) { rootElement.children.add(DelayElement(duration.inWholeMilliseconds)) } internal fun build(): HapticPattern = HapticPattern(rootElement.collectEvents(startTimeMs = 0L)) } fun buildHapticPattern(block: HapticPatternScope.() -> Unit): HapticPattern = HapticPatternScope().apply(block).build() 2026 COSCUP × UBUCON ASIA, TAIWAN 30
  24. Core Module is already a library val pattern = buildHapticPattern

    { haptic(100.ms) delay(50.ms) repeat(2) { haptic(30.ms, HapticIntensity.STRONG) } } • A plain Kotlin DSL — no Compose anywhere • HapticExecutor — expect/actual: Android Vibrator, iOS Core Haptics lifecycleScope.launch { pattern.play() } 2026 COSCUP × UBUCON ASIA, TAIWAN 31
  25. What the Compose Runtime Actually Does @Composable functions Compiler adds

    tracking Composition Remembers what was emitted, and where • Tracks which composables emitted which nodes, and in what order • On re-run, diffs against the previous state — computes the minimal change • The node type is generic. The runtime never names it Diff: what changed since last time Applier The only part that touches your tree Node Tree 2026 COSCUP × UBUCON ASIA, TAIWAN 32
  26. Applier internal class JindongApplier( root: HapticElement, ) : AbstractApplier<HapticElement>(root) {

    override fun insertTopDown(index: Int, instance: HapticElement) { current.children.add(index, instance) } override fun remove(index: Int, count: Int) { current.children.subList(index, index + count).clear() } • The runtime decides what changes — the Applier decides how • Insert, remove, move — every one touches only children • current - where the runtime is in the tree, tracked by AbstractApplier • Compose UI has one too — it inserts LayoutNode instead override fun move(from: Int, to: Int, count: Int) { if (from == to) return val items = current.children.subList(from, from + count).toList() current.children.subList(from, from + count).clear() current.children.addAll(to, items) } } 2026 COSCUP × UBUCON ASIA, TAIWAN 33
  27. Wiring it up @Composable fun JindongScope.Haptic(duration: Duration, intensity: HapticIntensity =

    MEDIUM) { ComposeNode<VibrationElement, JindongApplier>( factory = { VibrationElement(duration.inWholeMilliseconds, intensity) }, update = { }, ) } • ComposableNode— one composable call, one node • Two type parameters: what to create + who inserts it • Composition(applier, recomposer) - where our Applier gets injected private class JindongCompositionHost { private val rootElement = SequenceElement() private val applier = JindongApplier(rootElement) private val recomposer = Recomposer(clock) private val composition = Composition(applier, recomposer) } 2026 COSCUP × UBUCON ASIA, TAIWAN 34
  28. Use Compose Runtime as a compiler internal fun compilePattern(content: @Composable

    JindongScope.() -> Unit) = JindongCompositionHost().use { host -> host.setContent(content) host.sendFrame() host.collectEvents() } • Compose once → collect → throw the tree away • The runtime is a DSL compiler here, not a renderer • remember caches, LaunchedEffect plays — the trigger lives in the outer composition @Composable fun Jindong(vararg keys: Any?, content: @Composable JindongScope.() -> Unit) { val pattern = rememberHapticPattern(*keys) { content() } val executor = LocalHapticExecutor.current LaunchedEffect(*keys) { executor.execute(pattern) } } 2026 COSCUP × UBUCON ASIA, TAIWAN 35
  29. Wrap Up @Composable Haptic/Delay Compose Node Composition Inserts into children

    • Core defines the tree — no Compose anywhere • The Applier lets Compose Runtime build that same tree • Both DSLs end up at the same event list HapticElement Tree collectEvents→ List< ScheduledHapticEvent> HapticExecutor Haptic Feedback 2026 COSCUP × UBUCON ASIA, TAIWAN 36