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

선언형 햅틱 라이브러리 진동을 소개합니다

Avatar for HyunWoo Lee HyunWoo Lee
November 01, 2026

선언형 햅틱 라이브러리 진동을 소개합니다

DroidKnights 2026에서 진행한 선언형 햅틱 라이브러리 진동을 소개합니다의 Speaker Deck입니다.

Avatar for HyunWoo Lee

HyunWoo Lee

November 01, 2026

More Decks by HyunWoo Lee

Other Decks in Programming

Transcript

  1. ✦ Hello 안녕하세요, 이현우입니다 @l2hyunwoo Android Engineer @Toss Organizer, @GDG

    Korea Android @KUG Seoul Kotlin Multiplatform & React Native Lover 02
  2. ✦ Jindong이 무엇인가 Jindong.android.kt val vibrator = if (Build.VERSION.SDK_INT >=

    31) { context.getSystemService(VibratorManager::class.java)?.defaultVibrator } else { context.getSystemService(Vibrator::class.java) } 05
  3. ✦ Jindong이 무엇인가 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: 0이 아닌 진폭은 모두 최대값으로 올림 처리됨 } 06
  4. ✦ Jindong이 무엇인가 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: 0이 아닌 진폭은 모두 최대값으로 올림 처리됨 } vibrator?.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1)) 07
  5. ✦ Jindong이 무엇인가 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 ] 09
  6. ✦ Jindong이 무엇인가 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
  7. ✦ Jindong이 무엇인가 명령형 vs 선언형 명령형 선언형 어떻게 할지를

    작성 무엇을 해야하는지를 작성 작업 완료에 필요한 모든 단계를 명시적으로 정의 작업 완료에 필요한 논리적 구조를 추상화 데이터 할당, 계산, 반복문, 조건문 같은 연산을 포함 함수와 타입으로 개념적 관계를 표현 컴퓨터가 명령을 실행하는 방식을 추상화 비즈니스 규칙, 데이터 관계, 상태 전이에 집중 13
  8. ✦ 왜 만들었는가 소리를 Compose로 렌더링할 수 있다면? 진동은 왜

    안 될까? DroidKaigi(일본 Android 컨퍼런스)에서 소리를 Composable 함수로 표현한 프로젝트를 봄 소리를 이렇게 표현할 수 있다면, 진동도 같은 방식으로 표현 할 수 있지 않을까? 15
  9. ✦ 왜 만들었는가 오픈소스 라이브러리를 만들기 전에 고려할 것들 01

    02 아이디어가 이미 존재하는지 확인 (선택) 좋은 협업자 찾기 Android와 Compose Multiplatform용 햅틱 라이브러리 혼자 작업하면 API 설계와 프로젝트 비전이 지나치게 한쪽 는 이미 여럿 있었지만, Compose Runtime 기반이거나 선 으로 치우칠 수 있음 언형 API를 제공하는 것은 없었음 좋은 협업자는 다른 관점을 더하고 프로젝트의 균형을 유지 하도록 도와줌 16
  10. ✦ 왜 만들었는가 피드백을 통해 Compose 기반에서 Kotlin 중심으로 Slack에

    첫 stable 릴리스를 발표한 후 피드백을 받음 개발자들은 Compose Runtime 기반 API만이 아닌 순수 Kotlin DSL을 원함 기존 설계를 재검토 처음에는 학습 곡선을 낮추기 위해 Compose Runtime을 선택 하지만 Kotlin DSL로도 비슷하게 간결하고 표현력 있는 API를 제공할 수 있음 라이브러리를 재구성 Kotlin 기반 API를 핵심 구현으로 삼음 Compose나 Compose Multiplatform을 사용하지 않는 개발자까지 지 원을 확장 17
  11. ✦ Jindong의 내부 햅틱 패턴을 트리로 표현하기 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) // … } 20
  12. ✦ Jindong의 내부 햅틱 패턴을 트리로 표현하기 fun Haptic() {

    ShortHatpic() ShortHatpic() ShortHatpic() LongHaptic() // … } fun ShortHaptic() { Haptic(50.ms) Delay(20.ms) } fun LongHaptic() { Haptic(100.ms) Delay(20.ms) } 21
  13. ✦ Jindong의 내부 햅틱 패턴을 트리로 표현하기 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) } 22
  14. ✦ Jindong의 내부 햅틱 패턴을 트리로 표현하기 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) } 23
  15. ✦ Jindong의 내부 Jindong 모듈 jindong-core jindong-compose 햅틱 패턴을 정의하는

    순수 Kotlin DSL Compose Runtime 기반 API HapticElement를 중심으로 구성 Composable 함수처럼 햅틱 패턴을 정의 커스텀 햅틱 효과로 쉽게 확장 가능 24
  16. ✦ Jindong의 내부 HapticElement 햅틱 패턴의 기본 구성 요소 interface

    HapticElement { val children: MutableList<HapticElement> fun collectEvents(startTimeMs: Long): List<ScheduledHapticEvent> fun totalDurationMs(startTimeMs: Long): Long } children 햅틱 패턴을 이루는 자식 노드 collectEvents 트리를 플랫폼 API가 소비할 수 있는 리스트로 평탄화 totalDurationMs “내가 끝났을 때 얼마나 시간이 지났는가”에 답함 부모는 이를 이용해 다음 형제의 시작 시점을 스케줄링 25
  17. ✦ Jindong의 내부 interface HapticElement { val children: MutableList<HapticElement> fun

    collectEvents(startTimeMs: Long): List<ScheduledHapticEvent> fun totalDurationMs(startTimeMs: Long): Long } 26
  18. ✦ Jindong의 내부 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 } 27
  19. ✦ Jindong의 내부 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 } 28
  20. ✦ Jindong의 내부 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 } } 29
  21. ✦ Jindong의 내부 @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() 30
  22. ✦ Jindong의 내부 Core 모듈은 이미 하나의 라이브러리 순수 Kotlin

    DSL — Compose는 어디에도 없음 val pattern = buildHapticPattern { haptic(100.ms) delay(50.ms) HapticExecutor — expect/actual: Android Vibrator, iOS Core Haptics repeat(2) { haptic(30.ms, HapticIntensity.STRONG) } } lifecycleScope.launch { pattern.play() } 31
  23. ✦ Jindong의 내부 Compose Runtime이 실제로 하는 일 @Composable 함수

    어떤 @Composable이 어떤 노드를 어떤 순서 로 방출했는지 추적 컴파일러가 추적 코드를 추가 Composition 무엇이 어디에 방출되었는지 기억 Diff: 지난번 이후 바뀐 것 재실행 시 이전 상태와 비교(diff)해 최소한의 변 경을 계산 노드 타입은 제네릭으로 정의 Applier 트리를 직접 다루는 유일한 부분 노드 트리 32
  24. ✦ Jindong의 내부 Applier 런타임은 무엇이 바뀌는지를 결정하고 — 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() } 삽입, 제거, 이동 — 모두 children만 건드 림 current - 런타임이 트리에서 현재 위 치한 곳, AbstractApplier가 추적 Compose UI에도 하나 있음 — 대신 LayoutNode를 삽입 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) } } 33
  25. ✦ Jindong의 내부 연결하기 ComposableNode — composable 호출 하나에 노드

    하나 @Composable fun JindongScope.Haptic(duration: Duration, intensity: HapticIntensity = MEDIUM) { ComposeNode<VibrationElement, JindongApplier>( factory = { VibrationElement(duration.inWholeMilliseconds, intensity) }, update = { }, ) } 두 개의 타입 파라미터: 무엇을 생성할지 + 누가 삽입할지 Composition(applier, recomposer) - 우리의 Applier가 주 입되는 곳 private class JindongCompositionHost { private val rootElement = SequenceElement() private val applier = JindongApplier(rootElement) private val recomposer = Recomposer(clock) private val composition = Composition(applier, recomposer) } 34
  26. ✦ Jindong의 내부 Compose Runtime을 컴파일러로 사용하기 한 번 compose

    → 수집 → 트리는 버림 internal fun compilePattern(content: @Composable JindongScope.() -> Unit) = JindongCompositionHost().use { host -> host.setContent(content) host.sendFrame() host.collectEvents() } 여기서 런타임은 렌더러가 아닌 DSL 컴파 일러 remember는 캐싱하고, LaunchedEffect는 재생 — 트리거는 바깥 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) } } 35
  27. ✦ Jindong의 내부 정리 @Composable Haptic/Delay Compose Node Composition children에

    삽입 Core가 트리를 정의 — Compose는 어디에도 없음 Applier 덕분에 Compose Runtime이 같은 트 리를 만들 수 있음 두 DSL 모두 결국 같은 이벤트 리스트에 도달 HapticElement 트리 collectEvents → List<ScheduledHapticEvent> HapticExecutor 햅틱 피드백 36
  28. ✦ T h a n k Yo u 감사합니다 @l2hyunwoo

    Jindong 저장소 Github LinkedIn