Compose pointerInput으로 드래그 순서 변경 구현하기 1편

벼리·2026년 4월 12일

Compose

목록 보기
9/10

들어가며

여러 애니메이션을 구현하다 보면, 저수준의 레벨에서 직접 커스텀해야하는 경우가 많습니다.

아래의 유튜브 뮤직 예시를 보겠습니다.

유튜브 뮤직에서는 아이템을 드래그하여 아이템의 순서를 실시간으로 바꾸고 있습니다. 이를 구현하기 위해 Compose에서 gesture event가 어떻게 처리되는지, 그리고 커스텀하기 위해 어떤 api들을 제공하고 있는지 알아보고자 합니다.

1. Compose 제스처 처리의 3단계 추상화

Compose에서 제스처를 처리하는 방법은 추상화 수준에 따라 크게 세 가지로 나뉩니다. 공식문서에서는 이를 "가장 높은 수준"에서 "가장 낮은 수준"으로 분류하고 있습니다.

Level 1: 컴포넌트 수준 (가장 높은 추상화)

Button, LazyColumn, HorizontalPager 등의 컴포넌트는 자체적으로 제스처 처리를 내장하고 있습니다. 별도의 제스처 코드 없이 컴포넌트를 사용하기만 하면 클릭, 스크롤, 스와이프 등이 자동으로 동작합니다.

// 별도 코드 불필요 - Button이 알아서 처리합니다
Button(onClick = { /* ... */ }) {
    Text("Click me")
}

Level 2: 제스처 Modifier (중간 추상화)

clickable, draggable, transformable 같은 Modifier를 사용하면 특정 제스처 동작을 간편하게 추가할 수 있습니다. 컴포넌트에 제스처가 내장되어 있지 않을 때, 혹은 기본 동작을 커스텀하고 싶을 때 사용합니다.

Box(
    modifier = Modifier
        .clickable { /* 클릭 처리 */ }
        .draggable(
            state = rememberDraggableState { delta -> /* 드래그 처리 */ },
            orientation = Orientation.Horizontal
        )
)

Level 3: pointerInput (가장 낮은 추상화)

pointerInput은 Compose 제스처 시스템의 가장 낮은 수준입니다. 포인터 이벤트를 직접 관찰하고 처리할 수 있어 최대한의 유연성을 제공합니다. 다른 API로는 불가능한 복잡한 제스처 로직을 구현할 때 사용합니다.

Modifier.pointerInput(Unit) {
    // 여기서 원하는 제스처 로직을 자유롭게 구현합니다
}

왜 이 프로젝트에서 Level 3이 필요했는가

유튜브 뮤직과 같은 드래그 순서 변경 기능을 구현하려면 다음과 같은 로직들이 필요합니다.

  • 스크롤 이벤트와 순서 변경 이벤트 구분
  • 드래그 이벤트를 소비하여 부모 스크롤과의 충돌 방지
  • 드래그 offset을 계산하여 item을 옮길 위치 계산
  • 현재 화면에 옮기고자 하는 item의 위치가 안 보이면 자동으로 scroll
  • 헤더처럼 드래그 대상이 아닌 아이템은 건너뛰기

이런 요구사항은 Level 2 API로는 처리할 수 없었기 때문에 pointerInput을 직접 다루게 되었습니다.


2. pointerInput 핵심 개념

구현하기 앞서 pointerInput에서 사용하는 개념들과 중요한 포인트를 정리하겠습니다.

Pointer, PointerEvent, Gesture

공식문서에서 정의하는 기본 용어들입니다.

  • Pointer: 화면에 닿는 하나의 접촉점입니다. 손가락 하나, 스타일러스 하나가 각각 하나의 Pointer입니다.
  • PointerEvent: 특정 시점에 발생한 포인터 변경 정보의 모음입니다. 위치 변경, 누르기/떼기 상태 변경 등이 포함됩니다.
  • Gesture: Pointer가 Down된 시점부터 마지막 Pointer가 Up되는 시점까지의 일련의 PointerEvent 시퀀스입니다.

pointerInput modifier의 key 파라미터

Modifier.pointerInput(key1, key2) {
    // PointerInputScope 안에서 제스처를 감지합니다
}

pointerInputkey 파라미터는 매우 중요합니다. key 값이 변경되면 현재 진행 중인 제스처 감지 코루틴이 취소되고 새로 시작됩니다. 반대로 key가 Unit이면 Composable이 살아있는 동안 한 번만 시작됩니다.

key에 상태 값을 전달하면, 해당 상태가 변경될 때 제스처 처리 로직이 최신 값으로 재시작됩니다. 람다 캡처 문제를 방지하는 핵심 메커니즘입니다.

DraggableListSample.kt에서는 lazyColumnDraggableListStatelongPressTimeoutMillis 두 가지를 key로 전달합니다. 상태 객체가 바뀌면 제스처 감지도 즉시 재시작됩니다.

// DraggableListSample.kt
fun Modifier.dragContainerWithCustomLongPress(
    state: SampleDragDropState,
    longPressTimeoutMillis: Long = 200L,
): Modifier = pointerInput(state, longPressTimeoutMillis) {
    awaitEachGesture { /* ... */ }
}

💡상태 객체란
드래그 중인 아이템의 인덱스, 누적 offset, 스크롤 채널 등 제스처 처리에 필요한 값들을 보관하는 인스턴스를 의미합니다. 이 인스턴스가 새로 생성되어 바뀌면 제스처 감지도 즉시 재시작됩니다.
해당 내용은 2편에서 다룰 예정입니다

awaitPointerEventScope vs awaitEachGesture

  • awaitPointerEventScope: 단일 제스처 시퀀스를 처리합니다. 한 번의 down-up 사이클이 끝나면 수동으로 다시 호출해야 합니다.
  • awaitEachGesture: 제스처가 끝날 때마다 자동으로 다음 제스처를 기다립니다. 내부적으로 awaitPointerEventScope를 반복 호출하는 래퍼입니다. 대부분의 경우 이것을 사용하면 됩니다.
// awaitEachGesture를 사용하면 제스처가 끝나도 자동으로 다음 제스처를 기다립니다
Modifier.pointerInput(Unit) {
    awaitEachGesture {
        val down = awaitFirstDown()
        // ... 제스처 처리
        // 블록이 끝나면 자동으로 다음 제스처를 기다립니다
    }
}

이벤트 소비 (consume)

중첩된 제스처 핸들러가 공존할 때 "이 이벤트는 내가 이미 처리했다"라고 표시하는 메커니즘입니다. 뒤에 구현할 드래그 정렬에서 반복적으로 등장하므로 먼저 개념을 짚고 가겠습니다.

  • change.consume(): 해당 PointerInputChange를 "소비됨" 상태로 바꿉니다. 다른 핸들러는 이 이벤트를 자신의 동작(예: 스크롤)에 사용하지 않습니다.
  • change.isConsumed: 이미 다른 핸들러가 소비한 이벤트인지 확인하는 플래그입니다.
  • awaitFirstDown(requireUnconsumed = false): 이미 소비된 down 이벤트도 받겠다는 옵션입니다. 중첩 스크롤 컨테이너 안에서 부모가 down을 먼저 소비해버려 자식이 아무것도 못 받는 상황을 피할 때 사용합니다.

이 세 가지는 LazyColumn 안에서 드래그를 구현할 때 "부모 스크롤과 자식 드래그의 충돌"을 푸는 핵심 도구가 됩니다. 구체적인 사용 예시는 구현 섹션에서 다시 살펴보겠습니다.


3. 이벤트 전파 3단계 (PointerEventPass)

Compose의 포인터 이벤트는 UI 트리를 따라 세 단계를 거쳐 전파됩니다. 각 단계마다 다른 노드가 이벤트를 먼저 받을 기회를 갖습니다.

Initial Pass

이벤트가 부모에서 자식 방향으로 전파됩니다. 부모 컴포저블이 자식보다 먼저 이벤트를 받습니다. 부모가 이벤트를 가로채거나, 자식이 받기 전에 전처리를 해야 할 때 사용합니다.

val down = awaitFirstDown(false, PointerEventPass.Initial)
// 자식이 받기 전에 부모가 먼저 down 이벤트를 처리합니다

Main Pass (기본값)

이벤트가 자식에서 부모 방향으로 전파됩니다. awaitPointerEvent()를 파라미터 없이 호출하면 기본적으로 Main 패스를 사용합니다. 일반적인 제스처 처리에서 가장 많이 사용하는 패스입니다.

val event = awaitPointerEvent(PointerEventPass.Main) // 또는 그냥 awaitPointerEvent()

커스텀 롱프레스 구현의 대기 루프에서 Main 패스를 사용합니다. 자식이 이벤트를 소비했는지 확인하는 데 적합합니다.

Final Pass

이벤트가 부모에서 자식 방향으로 다시 전파됩니다. Main 패스가 끝난 후 최종 정리 단계입니다. 다른 노드가 이벤트를 소비했는지 확인하거나, 최후에 이벤트 처리 여부를 결정할 때 사용합니다.

언제 어떤 패스를 사용하는가

Pass방향사용 시나리오
Initial부모 → 자식부모가 자식보다 먼저 이벤트를 가로채야 할 때
Main자식 → 부모일반적인 제스처 처리 (기본값)
Final부모 → 자식이벤트 소비 여부 최종 확인, 정리 작업

샘플 앱의 awaitCustomLongPress 대기 루프에서 Main 패스를 사용합니다. 자식이 이벤트를 소비했는지 확인하는 데 적합합니다.

4. DraggableListSample 전체 구조 분석

본격적인 구현에 들어가기 전에, 우리가 만들 화면의 전체 구조를 먼저 살펴보겠습니다. 이후 섹션에서 등장할 코드 조각들이 어느 위치에서 동작하는지 머릿속 지도를 그려두면 읽기가 훨씬 편해집니다.

샘플은 하나의 LazyColumn 안에서 두 개의 독립적인 드래그 시스템을 관리합니다.

  • 수직 드래그: Task 카드 목록의 순서를 바꿉니다.
  • 수평 드래그: 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: 타겟 아이템 후보를 찾을 때도 가드를 걸어, 태스크가 헤더 자리로 스왑되는 것을 막습니다.

수직 vs 수평 — 두 개의 드래그 상태

수직/수평 드래그는 축(x vs y)만 다를 뿐 구조가 동일합니다. 샘플에서는 두 클래스를 분리해 두었습니다.

구분수직 드래그수평 드래그
상태SampleDragDropStateSampleRowDragDropState
ModifierModifier.dragContainerWithCustomLongPressModifier.dragRowContainerWithCustomLongPress
아이템 래퍼SampleDraggableItem (translationY)SampleRowDraggableItem (translationX)
히트 테스트 축offset.yoffset.x

이 구조를 염두에 두고, 이제 드래그를 실제로 감지하는 제스처 레이어부터 아래에서부터 쌓아 올려보겠습니다.


5. 고수준 제스처 감지기 - detectDragGesturesAfterLongPress

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초를 기다려야 합니다. 이 샘플에서는 더 빠른 반응이 필요했기 때문에 커스텀 구현이 필요했습니다.


6. 커스텀 롱프레스 구현 - 왜 기본 API로는 부족했는가

기본 detectDragGesturesAfterLongPress는 내부적으로 시스템의 ViewConfiguration.longPressTimeoutMillis(약 400~500ms)를 사용합니다. 이 값은 변경할 수 없습니다.

샘플 앱에서는 200ms의 빠른 반응이 필요했습니다. 약 400~500ms는 유저에게 긴 시간이기 때문에 사용성에 불편함을 초래하기 때문입니다.

detectDragGesturesLazyColumm의 스크롤 이벤트와 겹치기 때문에 드래그 제스처를 인지할 수 없습니다.

이를 해결하기 위해 dragContainerWithCustomLongPressawaitCustomLongPress를 직접 구현했습니다.

MainActivity에서의 사용

// 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)으로 이벤트를 계속 확인합니다. 아래 세 가지 중 하나가 발생하면 루프를 즉시 종료합니다.

  • 해당 포인터 ID를 찾을 수 없는 경우 (손가락이 화면에서 벗어남)
  • 이벤트가 이미 소비된 경우 (change.isConsumed)
  • 손가락을 뗀 경우 (!change.pressed)

이 경우 withTimeoutOrNullnull이 아닌 값(Unit)을 반환하며, 롱프레스로 간주하지 않습니다. 특히 change.isConsumed 체크가 중요한데, 다른 제스처 핸들러(예: 중첩된 스크롤)가 이미 이벤트를 소비했다면 롱프레스 감지를 즉시 취소하여 의도치 않은 드래그 시작을 방지합니다.

3단계: 타임아웃 = 롱프레스 성공

withTimeoutOrNullnull을 반환했다는 것은 지정한 시간이 지나도록 루프가 종료되지 않았다는 의미입니다. 즉, 손가락이 200ms 이상 화면에 눌려있었습니다. 이것이 롱프레스 성공 조건입니다.

타임아웃 후 currentEvent에서 해당 포인터의 최신 변경 정보를 가져와 반환합니다. 이 시점에도 손가락이 여전히 눌려있고(change.pressed) 소비되지 않은(!change.isConsumed) 경우에만 유효한 롱프레스로 처리합니다.

4단계: drag() - 연속 드래그 추적

drag(longPressChange.id) 함수는 해당 포인터 ID의 드래그 이벤트를 연속으로 추적합니다. 손가락이 움직일 때마다 콜백이 호출되며, 각 콜백에서 change.consume()을 호출하여 부모 LazyColumn의 스크롤을 막습니다.

그리고 이 콜백 안에서 호출하는 change.consume()이 바로 부모 LazyColumn의 스크롤을 차단하는 장치입니다. 이것을 생략하면 손가락을 움직이는 순간 부모 LazyColumn이 동일한 이벤트를 스크롤로 해석해, 드래그 정렬과 스크롤이 동시에 일어나는 충돌이 발생합니다.


1편 마무리

이때까지 어떻게 제스처를 추적하는지를 알아보았습니다. 그렇다면, 이 제스처를 어떻게 state로 저장해서 관리할 수 있을까요?

다음 편에서 알아보도록 하겠습니다!

profile
코딩일기

0개의 댓글