여러 애니메이션을 구현하다 보면, 저수준의 레벨에서 직접 커스텀해야하는 경우가 많습니다.
아래의 유튜브 뮤직 예시를 보겠습니다.

유튜브 뮤직에서는 아이템을 드래그하여 아이템의 순서를 실시간으로 바꾸고 있습니다. 이를 구현하기 위해 Compose에서 gesture event가 어떻게 처리되는지, 그리고 커스텀하기 위해 어떤 api들을 제공하고 있는지 알아보고자 합니다.
Compose에서 제스처를 처리하는 방법은 추상화 수준에 따라 크게 세 가지로 나뉩니다. 공식문서에서는 이를 "가장 높은 수준"에서 "가장 낮은 수준"으로 분류하고 있습니다.
Button, LazyColumn, HorizontalPager 등의 컴포넌트는 자체적으로 제스처 처리를 내장하고 있습니다. 별도의 제스처 코드 없이 컴포넌트를 사용하기만 하면 클릭, 스크롤, 스와이프 등이 자동으로 동작합니다.
// 별도 코드 불필요 - Button이 알아서 처리합니다
Button(onClick = { /* ... */ }) {
Text("Click me")
}
clickable, draggable, transformable 같은 Modifier를 사용하면 특정 제스처 동작을 간편하게 추가할 수 있습니다. 컴포넌트에 제스처가 내장되어 있지 않을 때, 혹은 기본 동작을 커스텀하고 싶을 때 사용합니다.
Box(
modifier = Modifier
.clickable { /* 클릭 처리 */ }
.draggable(
state = rememberDraggableState { delta -> /* 드래그 처리 */ },
orientation = Orientation.Horizontal
)
)
pointerInput은 Compose 제스처 시스템의 가장 낮은 수준입니다. 포인터 이벤트를 직접 관찰하고 처리할 수 있어 최대한의 유연성을 제공합니다. 다른 API로는 불가능한 복잡한 제스처 로직을 구현할 때 사용합니다.
Modifier.pointerInput(Unit) {
// 여기서 원하는 제스처 로직을 자유롭게 구현합니다
}
유튜브 뮤직과 같은 드래그 순서 변경 기능을 구현하려면 다음과 같은 로직들이 필요합니다.
이런 요구사항은 Level 2 API로는 처리할 수 없었기 때문에 pointerInput을 직접 다루게 되었습니다.
구현하기 앞서 pointerInput에서 사용하는 개념들과 중요한 포인트를 정리하겠습니다.
공식문서에서 정의하는 기본 용어들입니다.
Modifier.pointerInput(key1, key2) {
// PointerInputScope 안에서 제스처를 감지합니다
}
pointerInput의 key 파라미터는 매우 중요합니다. key 값이 변경되면 현재 진행 중인 제스처 감지 코루틴이 취소되고 새로 시작됩니다. 반대로 key가 Unit이면 Composable이 살아있는 동안 한 번만 시작됩니다.
key에 상태 값을 전달하면, 해당 상태가 변경될 때 제스처 처리 로직이 최신 값으로 재시작됩니다. 람다 캡처 문제를 방지하는 핵심 메커니즘입니다.
DraggableListSample.kt에서는 lazyColumnDraggableListState와 longPressTimeoutMillis 두 가지를 key로 전달합니다. 상태 객체가 바뀌면 제스처 감지도 즉시 재시작됩니다.
// DraggableListSample.kt
fun Modifier.dragContainerWithCustomLongPress(
state: SampleDragDropState,
longPressTimeoutMillis: Long = 200L,
): Modifier = pointerInput(state, longPressTimeoutMillis) {
awaitEachGesture { /* ... */ }
}
💡상태 객체란
드래그 중인 아이템의 인덱스, 누적 offset, 스크롤 채널 등 제스처 처리에 필요한 값들을 보관하는 인스턴스를 의미합니다. 이 인스턴스가 새로 생성되어 바뀌면 제스처 감지도 즉시 재시작됩니다.
해당 내용은 2편에서 다룰 예정입니다
awaitPointerEventScope: 단일 제스처 시퀀스를 처리합니다. 한 번의 down-up 사이클이 끝나면 수동으로 다시 호출해야 합니다.awaitEachGesture: 제스처가 끝날 때마다 자동으로 다음 제스처를 기다립니다. 내부적으로 awaitPointerEventScope를 반복 호출하는 래퍼입니다. 대부분의 경우 이것을 사용하면 됩니다.// awaitEachGesture를 사용하면 제스처가 끝나도 자동으로 다음 제스처를 기다립니다
Modifier.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
// ... 제스처 처리
// 블록이 끝나면 자동으로 다음 제스처를 기다립니다
}
}
중첩된 제스처 핸들러가 공존할 때 "이 이벤트는 내가 이미 처리했다"라고 표시하는 메커니즘입니다. 뒤에 구현할 드래그 정렬에서 반복적으로 등장하므로 먼저 개념을 짚고 가겠습니다.
change.consume(): 해당 PointerInputChange를 "소비됨" 상태로 바꿉니다. 다른 핸들러는 이 이벤트를 자신의 동작(예: 스크롤)에 사용하지 않습니다.change.isConsumed: 이미 다른 핸들러가 소비한 이벤트인지 확인하는 플래그입니다.awaitFirstDown(requireUnconsumed = false): 이미 소비된 down 이벤트도 받겠다는 옵션입니다. 중첩 스크롤 컨테이너 안에서 부모가 down을 먼저 소비해버려 자식이 아무것도 못 받는 상황을 피할 때 사용합니다.이 세 가지는 LazyColumn 안에서 드래그를 구현할 때 "부모 스크롤과 자식 드래그의 충돌"을 푸는 핵심 도구가 됩니다. 구체적인 사용 예시는 구현 섹션에서 다시 살펴보겠습니다.
Compose의 포인터 이벤트는 UI 트리를 따라 세 단계를 거쳐 전파됩니다. 각 단계마다 다른 노드가 이벤트를 먼저 받을 기회를 갖습니다.
이벤트가 부모에서 자식 방향으로 전파됩니다. 부모 컴포저블이 자식보다 먼저 이벤트를 받습니다. 부모가 이벤트를 가로채거나, 자식이 받기 전에 전처리를 해야 할 때 사용합니다.
val down = awaitFirstDown(false, PointerEventPass.Initial)
// 자식이 받기 전에 부모가 먼저 down 이벤트를 처리합니다
이벤트가 자식에서 부모 방향으로 전파됩니다. awaitPointerEvent()를 파라미터 없이 호출하면 기본적으로 Main 패스를 사용합니다. 일반적인 제스처 처리에서 가장 많이 사용하는 패스입니다.
val event = awaitPointerEvent(PointerEventPass.Main) // 또는 그냥 awaitPointerEvent()
커스텀 롱프레스 구현의 대기 루프에서 Main 패스를 사용합니다. 자식이 이벤트를 소비했는지 확인하는 데 적합합니다.
이벤트가 부모에서 자식 방향으로 다시 전파됩니다. Main 패스가 끝난 후 최종 정리 단계입니다. 다른 노드가 이벤트를 소비했는지 확인하거나, 최후에 이벤트 처리 여부를 결정할 때 사용합니다.
| Pass | 방향 | 사용 시나리오 |
|---|---|---|
Initial | 부모 → 자식 | 부모가 자식보다 먼저 이벤트를 가로채야 할 때 |
Main | 자식 → 부모 | 일반적인 제스처 처리 (기본값) |
Final | 부모 → 자식 | 이벤트 소비 여부 최종 확인, 정리 작업 |
샘플 앱의 awaitCustomLongPress 대기 루프에서 Main 패스를 사용합니다. 자식이 이벤트를 소비했는지 확인하는 데 적합합니다.
본격적인 구현에 들어가기 전에, 우리가 만들 화면의 전체 구조를 먼저 살펴보겠습니다. 이후 섹션에서 등장할 코드 조각들이 어느 위치에서 동작하는지 머릿속 지도를 그려두면 읽기가 훨씬 편해집니다.
샘플은 하나의 LazyColumn 안에서 두 개의 독립적인 드래그 시스템을 관리합니다.
LazyRow(Featured 섹션)에서 아이템 순서를 바꿉니다.LazyColumn (수직 드래그 컨테이너)
├── [0] SampleFeaturedHeader ← 드래그 불가 (헤더)
├── [1] SampleFeaturedRow ← 드래그 불가 (내부 LazyRow가 수평 드래그 담당)
│ └── LazyRow
│ └── SampleRowDraggableItem × N
├── [2] "Reorder your tasks" 타이틀 ← 드래그 불가 (헤더)
└── [3..] SampleDraggableItem × N ← 수직 드래그 대상

LazyColumn의 전역 인덱스로 따지면 0~2번은 헤더 영역이고 3번부터가 실제 태스크 카드입니다. MainActivity.kt에서는 이 경계를 TASKS_START = 3 상수로 명시해 두었습니다.
canDragItem헤더까지 함께 스크롤되게 하려면 LazyColumn 하나에 전부 담아야 하지만, 그러면 사용자가 헤더를 길게 눌렀을 때 헤더 자체가 드래그되는 문제가 생깁니다. 이를 막기 위해 SampleDragDropState는 생성 시 canDragItem: (Int) -> Boolean 가드를 받습니다.
// MainActivity.kt
val dragDropState = rememberDragDropState(
lazyListState = lazyListState,
onMove = { from, to ->
// LazyColumn 글로벌 인덱스 → tasks 리스트 인덱스로 변환
tasks.add(to - TASKS_START, tasks.removeAt(from - TASKS_START))
},
canDragItem = { index -> index >= TASKS_START },
)
canDragItem은 두 지점에서 호출됩니다.
1. onDragStart: 히트 테스트로 찾은 아이템의 인덱스가 드래그 가능한지 검사합니다. 불가능하면 isDragging을 켜지 않고 조용히 무시합니다.
2. onDrag: 타겟 아이템 후보를 찾을 때도 가드를 걸어, 태스크가 헤더 자리로 스왑되는 것을 막습니다.
수직/수평 드래그는 축(x vs y)만 다를 뿐 구조가 동일합니다. 샘플에서는 두 클래스를 분리해 두었습니다.
| 구분 | 수직 드래그 | 수평 드래그 |
|---|---|---|
| 상태 | SampleDragDropState | SampleRowDragDropState |
| Modifier | Modifier.dragContainerWithCustomLongPress | Modifier.dragRowContainerWithCustomLongPress |
| 아이템 래퍼 | SampleDraggableItem (translationY) | SampleRowDraggableItem (translationX) |
| 히트 테스트 축 | offset.y | offset.x |
이 구조를 염두에 두고, 이제 드래그를 실제로 감지하는 제스처 레이어부터 아래에서부터 쌓아 올려보겠습니다.
detectDragGesturesAfterLongPress는 Compose가 기본 제공하는, 롱프레스 후 드래그를 감지하는 고수준 함수입니다. 롱프레스가 완료되면 onDragStart를 호출하고, 이후 손가락이 움직일 때마다 onDrag를 호출합니다. 손가락을 떼면 onDragEnd, 제스처가 취소되면 onDragCancel이 호출됩니다.
아래는 기본 API만 사용한 가장 단순한 버전입니다. 샘플에서는 최종적으로 커스텀 버전을 사용하지만, 이 기본 버전이 어떤 구조인지 먼저 이해하는 것이 중요합니다.
fun Modifier.dragContainer(state: SampleDragDropState): Modifier {
return pointerInput(state) {
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consume()
state.onDrag(offset = offset)
},
onDragStart = { offset -> state.onDragStart(offset) },
onDragEnd = { state.onDragInterrupted() },
onDragCancel = { state.onDragInterrupted() },
)
}
}
구조는 단순합니다. pointerInput의 key로 상태 객체를 전달하고, 내부에서 detectDragGesturesAfterLongPress에 각 이벤트 콜백을 연결합니다. onDrag에서 change.consume()을 호출하여 부모 LazyColumn이 스크롤되지 않도록 이벤트를 소비합니다.
문제는 detectDragGesturesAfterLongPress의 기본 롱프레스 시간이 약 400~500ms라는 점입니다. 사용자가 드래그를 시작하려면 거의 0.5초를 기다려야 합니다. 이 샘플에서는 더 빠른 반응이 필요했기 때문에 커스텀 구현이 필요했습니다.
기본 detectDragGesturesAfterLongPress는 내부적으로 시스템의 ViewConfiguration.longPressTimeoutMillis(약 400~500ms)를 사용합니다. 이 값은 변경할 수 없습니다.
샘플 앱에서는 200ms의 빠른 반응이 필요했습니다. 약 400~500ms는 유저에게 긴 시간이기 때문에 사용성에 불편함을 초래하기 때문입니다.
detectDragGestures는 LazyColumm의 스크롤 이벤트와 겹치기 때문에 드래그 제스처를 인지할 수 없습니다.
이를 해결하기 위해 dragContainerWithCustomLongPress와 awaitCustomLongPress를 직접 구현했습니다.
// MainActivity.kt
LazyColumn(
state = lazyListState,
modifier = modifier
.fillMaxSize()
.background(Color(0xFFF2F4F7))
.dragContainerWithCustomLongPress(state = dragDropState),
/* ... */
)
dragContainerWithCustomLongPress는 200ms의 짧은 롱프레스를 적용한 버전입니다.
// DraggableListSample.kt
fun Modifier.dragContainerWithCustomLongPress(
state: SampleDragDropState,
longPressTimeoutMillis: Long = 200L,
): Modifier = pointerInput(state, longPressTimeoutMillis) {
awaitEachGesture {
// requireUnconsumed = false: 부모가 이미 소비한 경우에도 down 을 받는다
val down = awaitFirstDown(requireUnconsumed = false)
val longPressChange = awaitCustomLongPress(down, longPressTimeoutMillis)
if (longPressChange != null) {
state.onDragStart(longPressChange.position)
// drag()는 포인터가 떼질 때까지 onDrag 콜백을 반복 호출
drag(longPressChange.id) { change ->
state.onDrag(change.positionChange())
// 부모(LazyColumn 스크롤)가 이벤트를 가로채지 못하게 소비
change.consume()
}
state.onDragInterrupted()
}
}
}
// 롱프레스 판정 헬퍼
private suspend fun AwaitPointerEventScope.awaitCustomLongPress(
initialDown: PointerInputChange,
longPressTimeoutMillis: Long,
): PointerInputChange? {
// withTimeoutOrNull 이 null 반환 = 타임아웃 경과 = 롱프레스 성립
val cancelled = withTimeoutOrNull(longPressTimeoutMillis) {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Main)
val change = event.changes.fastFirstOrNull { it.id == initialDown.id }
// 포인터가 사라졌거나 이미 소비됐거나 떼진 경우 → 롱프레스 아님
if (change == null || change.isConsumed || !change.pressed) {
return@withTimeoutOrNull Unit
}
}
}
if (cancelled == null) {
val change = currentEvent.changes.fastFirstOrNull { it.id == initialDown.id }
if (change != null && change.pressed && !change.isConsumed) {
return change
}
}
return null
}

1단계: awaitFirstDown(requireUnconsumed = false)
손가락이 화면에 닿는 순간을 기다립니다. 앞서 살펴본 이벤트 소비 개념이 여기서 바로 적용됩니다. requireUnconsumed = false로 설정한 이유는, 부모 LazyColumn이 이미 down 이벤트를 소비했더라도 자식인 드래그 핸들러가 여전히 이벤트를 받아야 하기 때문입니다. 중첩된 스크롤 컨테이너 안에서 흔히 발생하는 상황입니다.
2단계: withTimeoutOrNull 루프
지정한 시간(longPressTimeoutMillis, 기본 200ms) 동안 루프를 실행합니다. 루프 안에서는 awaitPointerEvent(PointerEventPass.Main)으로 이벤트를 계속 확인합니다. 아래 세 가지 중 하나가 발생하면 루프를 즉시 종료합니다.
change.isConsumed)!change.pressed)이 경우 withTimeoutOrNull은 null이 아닌 값(Unit)을 반환하며, 롱프레스로 간주하지 않습니다. 특히 change.isConsumed 체크가 중요한데, 다른 제스처 핸들러(예: 중첩된 스크롤)가 이미 이벤트를 소비했다면 롱프레스 감지를 즉시 취소하여 의도치 않은 드래그 시작을 방지합니다.
3단계: 타임아웃 = 롱프레스 성공
withTimeoutOrNull이 null을 반환했다는 것은 지정한 시간이 지나도록 루프가 종료되지 않았다는 의미입니다. 즉, 손가락이 200ms 이상 화면에 눌려있었습니다. 이것이 롱프레스 성공 조건입니다.
타임아웃 후 currentEvent에서 해당 포인터의 최신 변경 정보를 가져와 반환합니다. 이 시점에도 손가락이 여전히 눌려있고(change.pressed) 소비되지 않은(!change.isConsumed) 경우에만 유효한 롱프레스로 처리합니다.
4단계: drag() - 연속 드래그 추적
drag(longPressChange.id) 함수는 해당 포인터 ID의 드래그 이벤트를 연속으로 추적합니다. 손가락이 움직일 때마다 콜백이 호출되며, 각 콜백에서 change.consume()을 호출하여 부모 LazyColumn의 스크롤을 막습니다.
그리고 이 콜백 안에서 호출하는 change.consume()이 바로 부모 LazyColumn의 스크롤을 차단하는 장치입니다. 이것을 생략하면 손가락을 움직이는 순간 부모 LazyColumn이 동일한 이벤트를 스크롤로 해석해, 드래그 정렬과 스크롤이 동시에 일어나는 충돌이 발생합니다.
이때까지 어떻게 제스처를 추적하는지를 알아보았습니다. 그렇다면, 이 제스처를 어떻게 state로 저장해서 관리할 수 있을까요?
다음 편에서 알아보도록 하겠습니다!