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

벼리·2026년 4월 26일

Compose

목록 보기
10/10

들어가며

1편에서는 pointerInput의 핵심 개념과 커스텀 롱프레스 구현까지, 드래그 정렬의 제스처 감지 레이어를 완성했습니다. awaitFirstDownawaitCustomLongPressdrag()로 이어지는 200ms 커스텀 롱프레스가 state.onDragStart(), state.onDrag(delta), state.onDragInterrupted()를 호출해주는 지점까지 왔습니다.

2편에서는 이 콜백들을 받는 SampleDragDropState가 실제로 무엇을 하는지 들여다 보겠습니다. 손가락 delta를 받아 아이템 순서를 교환하고, 뷰포트 경계에서 자동 스크롤을 트리거하고, 손을 떼면 spring 애니메이션으로 복귀하는 상태 로직. 그리고 이 상태를 읽어 graphicsLayer + zIndex로 실시간 시각 피드백을 그려내는 컴포저블 레이어까지 살펴보겠습니다.


7. 드래그 상태 관리 - SampleDragDropState

1편에서 만든 제스처 감지 레이어는 이제 우리에게 세 가지 신호를 던져줍니다. "여기서부터 드래그 시작이야(onDragStart)", "이만큼 움직였어(onDrag)", "이제 손 뗐어(onDragInterrupted)". 그런데 이 신호만 받아서는 화면에 아무 일도 일어나지 않습니다. 지금 누가 드래그되고 있는지, 얼마나 움직였는지, 어디로 가야 하는지를 추적해 줄 누군가가 필요한 셈이지요. 그 역할을 맡는 클래스가 바로 SampleDragDropState입니다.

이 클래스가 풀어야 할 문제를 먼저 그려보면 다음과 같습니다.

  1. 손가락이 화면 어딘가를 누르면, 그 좌표 아래 어떤 아이템이 있는지 찾아내야 합니다.
  2. 손가락이 움직이면, 누적된 delta만큼 그 아이템을 시각적으로 밀어줘야 합니다.
  3. 밀린 아이템이 다른 아이템과 겹치면, 두 아이템의 순서를 교환해야 합니다.
  4. 아이템이 뷰포트 가장자리에 닿으면 LazyColumn을 자동 스크롤해야 합니다.
  5. 손을 떼면, 옆으로 비껴나 있던 아이템을 spring 애니메이션으로 제자리로 되돌려야 합니다.

이 다섯 가지를 표현하려면 결국 "지금 드래그 중인 아이템이 누구인가"와 "그 아이템이 시각적으로 얼마나 떨어져 있어야 하는가"라는 두 질문에 매 프레임 답할 수 있어야 합니다. SampleDragDropState의 필드들은 정확히 그 답을 만들어내기 위한 도구들입니다.

주요 필드

// DraggableListSample.kt
class SampleDragDropState internal constructor(
    private val lazyListState: LazyListState,
    private val scope: CoroutineScope,
    private val onMove: (from: Int, to: Int) -> Unit,
    private val canDragItem: (index: Int) -> Boolean = { true },
) {
    var draggingItemIndex by mutableStateOf<Int?>(null)   // 현재 드래그 중인 아이템의 인덱스
        private set
    var isDragging by mutableStateOf(false)                // 드래그 상태 여부
        private set
    internal var previousIndexOfDraggedItem by mutableStateOf<Int?>(null)  // 드래그 종료 후 복귀 애니메이션용
        private set
    internal var previousItemOffset = Animatable(0f)       // spring 애니메이션
        private set

    private val mutatorMutex = MutatorMutex()              // 동시성 안전
    internal val scrollChannel = Channel<Float>()          // 자동 스크롤용 채널
    private var draggingItemDraggedDelta by mutableFloatStateOf(0f)  // 누적 드래그 delta
    private var draggingItemInitialOffset by mutableIntStateOf(0)    // 드래그 시작 시 아이템 위치

    internal val draggingItemOffset: Float
        get() = draggingItemLayoutInfo?.let { item ->
            draggingItemInitialOffset + draggingItemDraggedDelta - item.offset
        } ?: 0f
}

각 필드를 앞서 정리한 다섯 가지 문제와 짝지어 보면 역할이 또렷해집니다.

draggingItemIndex — "지금 누가 드래그되고 있는가"

onDragStart(offset)에서 offset을 통해 현재 유저가 LazyColumn의 몇 번째 index를 드래그 중인지 계산합니다. draggingItemIndex 는 이 계산의 결과를 나타냅니다.null이면 "드래그 중인 아이템 없음"을 뜻합니다.

한 가지 미묘한 점은, 순서가 교환될 때마다 이 인덱스도 같이 갱신된다는 것입니다. 예를 들어 3번 아이템을 잡고 5번 자리까지 끌고 가는 동안 손가락은 계속 같은 카드를 누르고 있지만, LazyColumn 입장에서는 그 카드의 인덱스가 3 → 4 → 5로 바뀝니다. 그래서 뒤에서 살펴볼 onDrag 안에서 onMove(...) 호출 직후 draggingItemIndex = targetItem.index로 다시 써주는 것이고요. 이 한 줄이 빠지면 다음 프레임의 중간점 계산이 엉뚱한 아이템을 가리키게 되어 드래그가 어긋나기 시작합니다.

draggingItemDraggedDelta — "손가락이 얼마나 움직였는가"

onDrag(offset)이 호출될 때마다 offset.y를 더해 누적해 두는 값입니다. 절대 좌표가 아니라 드래그 시작 시점부터의 누적 이동량이라는 점이 핵심이며, 손을 떼는 순간 0f로 초기화됩니다.

draggingItemInitialOffset — "드래그 시작 시 아이템이 어디 있었는가"

onDragStart 시점에 LazyColumn 안에서의 아이템 위치(픽셀 단위)를 스냅샷으로 저장합니다. 이 값과 draggingItemDraggedDelta만 있으면 "지금 손가락이 가리키는 절대 위치"를 어디서든 복원할 수 있습니다.

draggingItemOffset — "지금 시각적으로 얼마나 비껴 있어야 하는가"

앞서 본 두 값과 LazyColumn의 현재 레이아웃 정보를 조합해 매 프레임 다시 계산되는 파생 값입니다. 시각 피드백 컴포저블이 graphicsLayer { translationY = state.draggingItemOffset }에 그대로 꽂아 쓰는, 사실상 드래그 정렬의 시각적 출력 핵심입니다.

internal val draggingItemOffset: Float
    get() = draggingItemLayoutInfo?.let { item ->
        draggingItemInitialOffset + draggingItemDraggedDelta - item.offset
    } ?: 0f

수식을 한 부분씩 떼어보면 이렇게 읽힙니다.

  • draggingItemInitialOffset + draggingItemDraggedDelta는 "원래 자리 + 그동안 누적된 이동량", 즉 손가락이 지금 가리키는 절대 위치입니다.
  • 여기서 item.offset(LazyColumn이 지금 이 아이템을 실제로 그려놓은 자리)을 빼면, 레이아웃이 그려준 위치를 기준으로 얼마나 더 비껴 보여야 하는지가 나옵니다.

왜 굳이 매번 item.offset을 빼주어야 할까요? onMove로 순서가 교환되거나 자동 스크롤이 일어나는 순간, 같은 카드라도 LazyColumn이 그려주는 item.offset이 휙 바뀝니다. 만약 누적 delta만 그대로 translationY에 꽂으면 아이템이 한 칸씩 점프하는 것처럼 보일 겁니다. item.offset을 매 프레임 다시 빼주기 때문에 레이아웃이 어떻게 출렁이든 카드가 손가락 아래에 정확히 붙어 있게 됩니다.

previousIndexOfDraggedItem + previousItemOffset — "방금 놓은 아이템을 부드럽게 되돌리기"

손을 뗀 직후에도 카드는 자기 시각적 위치에서 원래 자리(0f)까지 spring 애니메이션을 타고 부드럽게 돌아와야 합니다. 그동안에도 "누가 어떤 카드였는지", "얼마나 비껴 있었는지"를 기억해야 하므로, 드래그 종료 시점의 인덱스와 offset을 별도 필드로 옮겨 둡니다. 애니메이션이 끝나면 previousIndexOfDraggedItem = null로 비워 평상시 상태로 복귀합니다.

isDragging
드래그가 진행 중인지를 알리는 단순한 플래그입니다. 컴포저블은 이 값을 보고 graphicsLayer/zIndex를 켤지 말지 결정합니다.

MutatorMutex
아이템 순서 교환과 스크롤 조정이 동시에 일어날 때 충돌을 막아줍니다. 일반 Mutex가 "먼저 온 요청부터 처리"하는 큐라면, MutatorMutex는 "가장 최근 요청이 이긴다(last-one-wins)"는 정책으로 동작합니다. 새 mutate { } 블록이 들어오면 실행 중이던 블록을 즉시 취소시키고 새 블록을 실행하지요. 손가락이 빠르게 움직여 스왑 요청이 쏟아져도 옛 요청이 뒤늦게 실행되며 어긋나는 일이 없어, 드래그처럼 직전 작업을 덮어써도 되는 상황에 적합합니다.

scrollChannel

드래그 중인 아이템이 뷰포트 경계에 도달했을 때 자동 스크롤을 트리거합니다. Channel을 사용하여 코루틴 간 비동기 통신을 합니다.

canDragItem 가드

1편 4장에서 살펴본 헤더 보호용 가드입니다. onDragStartonDrag의 타겟 탐색 단계에서 모두 참조되어, 드래그 불가 아이템을 자연스럽게 건너뜁니다.

MainActivity에서의 초기화

// MainActivity.kt
val lazyListState = rememberLazyListState()
val dragDropState = rememberDragDropState(
    lazyListState = lazyListState,
    onMove = { from, to ->
        tasks.add(to - TASKS_START, tasks.removeAt(from - TASKS_START))
    },
    canDragItem = { index -> index >= TASKS_START },
)

rememberDragDropState는 상태 객체를 생성하고, scrollChannel의 이벤트를 LaunchedEffect로 수신하여 실제 스크롤을 실행합니다.

주요 메서드

onDragStart(offset) — 손가락 아래의 아이템 찾기

가장 먼저 풀어야 할 문제는 "이 좌표가 도대체 어느 아이템 위에 있는가"입니다. LazyColumn은 가상화된 리스트라 화면에 보이는 아이템만 실제로 컴포지션되어 있고, 각 아이템은 lazyListState.layoutInfo.visibleItemsInfo로 노출됩니다. 이 안의 LazyListItemInfo는 우리가 필요로 하는 두 정보, 인덱스(item.index)와 LazyColumn 좌표계 안에서의 픽셀 위치(item.offset, item.size)를 모두 들고 있습니다.

internal fun onDragStart(offset: Offset) {
    val hit = lazyListState.layoutInfo.visibleItemsInfo
        .firstOrNull { item ->
            offset.y.toInt() in item.offset..(item.offset + item.size)
        }
    if (hit != null && canDragItem(hit.index)) {
        draggingItemIndex = hit.index
        draggingItemInitialOffset = hit.offset
        isDragging = true
    }
}

offset.yitem.offset..(item.offset + item.size) 범위 안에 들어가는지 보는 것은, 결국 "터치 좌표가 이 아이템의 세로 범위 안인가"를 묻는 것과 같습니다. 첫 번째로 매치되는 아이템을 후보로 잡고, canDragItem 가드를 통과해야만 비로소 드래그가 시작됩니다(헤더처럼 드래그 불가 영역을 자연스럽게 무시하는 장치이지요).

매치된 시점에 세 가지 상태가 동시에 세팅된다는 점에 주목해 주세요. draggingItemIndex는 이후 모든 의사결정의 기준이 되고, draggingItemInitialOffset은 위에서 본 draggingItemOffset 계산식의 첫 번째 항을 채워줍니다. isDragging은 컴포저블이 시각 피드백을 켜는 트리거 역할을 합니다.

onDrag(offset) — 누적, 중간점, 그리고 스왑

드래그 중에는 매 프레임 두 가지 일이 동시에 일어나야 합니다. 시각적으로 카드를 따라 움직이게 하는 것과, 카드가 다른 아이템을 충분히 침범했을 때 순서를 교환하는 것입니다.

internal fun onDrag(offset: Offset) {
    draggingItemDraggedDelta += offset.y

    val draggingItem = draggingItemLayoutInfo ?: return
    val startOffset = draggingItem.offset + draggingItemOffset
    val endOffset = startOffset + draggingItem.size
    val middleOffset = startOffset + (endOffset - startOffset) / 2f

    val targetItem = lazyListState.layoutInfo.visibleItemsInfo.find { item ->
        middleOffset.toInt() in item.offset..(item.offset + item.size) &&
                draggingItem.index != item.index &&
                canDragItem(item.index) // 헤더와 스왑되지 않도록 가드
    }
    if (targetItem != null) {
        scope.launch {
            mutatorMutex.mutate {
                onMove(draggingItem.index, targetItem.index)
                draggingItemIndex = targetItem.index
            }
        }
    } else {
        // 뷰포트 끝에 닿으면 자동 스크롤 요청
        val overscroll = /* 경계 계산 */ 0f
        if (overscroll != 0f) {
            scrollChannel.trySend(overscroll)
        }
    }
}

코드의 흐름을 따라가며 짚어보겠습니다.

먼저 draggingItemDraggedDelta += offset.y로 누적 이동량을 갱신합니다. 이 한 줄이 갱신되는 순간 draggingItemOffset computed property의 결괏값도 따라 바뀌고, graphicsLayer로 그 값을 바라보고 있는 컴포저블이 다음 프레임에 자동으로 재합성되면서 카드가 손가락을 따라옵니다.

그 다음 블록은 "지금 이 카드가 다른 카드와 자리를 바꿀 만큼 움직였는가"를 판정하는 부분입니다.

val startOffset = draggingItem.offset + draggingItemOffset
val endOffset = startOffset + draggingItem.size
val middleOffset = startOffset + (endOffset - startOffset) / 2f

여기서 굳이 카드의 중간점을 쓰는 이유는, 드래그 중인 카드의 끝부분이 다른 카드를 살짝 스치는 정도로는 스왑이 일어나면 안 되기 때문입니다.

이렇게 구한 중간점이 다른 visible 아이템의 범위 안에 들어가면 그 아이템이 스왑 타겟이 됩니다. draggingItem.index != item.index 체크로 자기 자신과의 스왑을 막고, canDragItem(item.index) 가드로 헤더 같은 보호 영역을 또 한 번 걸러냅니다(타겟 탐색 단계에서도 가드를 다시 거는 이유가 여기에 있습니다 — onDragStart에서만 막으면 도중에 헤더 자리까지 끌고 들어갈 수 있으니까요).

타겟이 정해지면 mutatorMutex.mutate { } 안에서 두 가지를 한 묶음으로 처리합니다.

mutatorMutex.mutate {
    onMove(draggingItem.index, targetItem.index)
    draggingItemIndex = targetItem.index
}

onMove로 실제 데이터 리스트의 순서를 바꾸고, 그 직후 draggingItemIndex를 새 인덱스로 다시 써줍니다. 위에서 강조했듯이 이 한 줄이 빠지면 다음 프레임의 중간점 계산이 옛날 인덱스를 기준으로 돌아가기 때문에 드래그가 즉시 어긋납니다. 이 두 작업이 MutatorMutex 안에서 실행되기 때문에, 손가락이 빠르게 움직여 새 스왑 요청이 쏟아져도 옛 요청은 즉시 취소되고 가장 최근 상태만 살아남습니다.

타겟이 없을 때, 즉 화면 안에 더 이상 스왑할 만한 아이템이 없을 때는 뷰포트 경계까지 끌고 갔는지를 확인하고 scrollChannel.trySend(overscroll)로 자동 스크롤을 요청합니다. 실제 스크롤은 rememberDragDropStateLaunchedEffect에서 채널을 수신해 처리합니다.

onDragInterrupted()

드래그가 끝나거나 취소될 때 호출됩니다. 현재 시각적 offset을 시작점으로 하여 spring 애니메이션으로 원래 위치(0f)로 복귀합니다.

internal fun onDragInterrupted() {
    if (draggingItemIndex != null) {
        previousIndexOfDraggedItem = draggingItemIndex
        val startOffset = draggingItemOffset
        scope.launch {
            previousItemOffset.snapTo(startOffset)
            previousItemOffset.animateTo(
                targetValue = 0f,
                animationSpec = spring(
                    stiffness = Spring.StiffnessMediumLow,
                    visibilityThreshold = 1f,
                ),
            )
            previousIndexOfDraggedItem = null
        }
    }
    // 모든 상태 초기화
    draggingItemDraggedDelta = 0f
    draggingItemIndex = null
    draggingItemInitialOffset = 0
    isDragging = false
}


8. 시각적 피드백 - graphicsLayer와 zIndex

SampleDraggableItemSampleRowDraggableItem 컴포저블은 드래그 중인 아이템에 시각적 피드백을 제공합니다.

SampleDraggableItem의 draggingModifier

// DraggableListSample.kt
val draggingModifier = when {
    isDragging -> Modifier
        .zIndex(1f)
        .graphicsLayer { translationY = state.draggingItemOffset }

    index == state.previousIndexOfDraggedItem -> Modifier
        .zIndex(1f)
        .graphicsLayer { translationY = state.previousItemOffset.value }

    // 드래그 중이 아닌 나머지 아이템은 자연스럽게 위치 이동 애니메이션
    else -> Modifier.animateItem()
}

zIndex(1f): 드래그 중인 아이템이 다른 아이템 위에 렌더링됩니다. zIndex가 높을수록 앞에 그려지므로, 드래그 중인 아이템이 다른 아이템 위를 지나갈 때 가려지지 않습니다.

graphicsLayer { translationY }: 레이아웃을 실제로 변경하지 않고 GPU에서 시각적 위치만 이동합니다. 레이아웃 패스를 거치지 않으므로 성능이 훨씬 좋습니다. translationYdraggingItemOffset을 전달하여 손가락의 움직임을 실시간으로 반영합니다.

previousItemOffset + spring 애니메이션: 드래그가 끝나면 previousIndexOfDraggedItem으로 마킹된 아이템이 spring 애니메이션으로 원래 위치로 자연스럽게 복귀합니다.

animateItem(): 드래그되지 않는 아이템들이 순서를 바꿀 때 부드럽게 이동합니다. LazyColumn의 내장 애니메이션 기능입니다.

MainActivity에서의 사용

// MainActivity.kt
itemsIndexed(tasks, key = { _, task -> task.id }) { index, task ->
    SampleDraggableItem(
        state = dragDropState,
        index = index + TASKS_START, // 상단 3개의 헤더 item 을 건너뛴 실제 포지션
        modifier = Modifier.padding(horizontal = 16.dp),
    ) { isDragging ->
        SampleTaskCard(task = task, isDragging = isDragging)
    }
}

수평 버전 — SampleRowDraggableItem

SampleRowDraggableItem은 수평 방향이므로 translationX를 사용한다는 점에서만 다릅니다. 나머지 구조는 동일합니다.

val draggingModifier = when {
    isDragging -> Modifier
        .zIndex(1f)
        .graphicsLayer { translationX = state.draggingItemOffset }

    index == state.previousIndexOfDraggedItem -> Modifier
        .zIndex(1f)
        .graphicsLayer { translationX = state.previousItemOffset.value }

    else -> Modifier.animateItem()
}

Featured Row 내부에서 각 칩을 이 래퍼로 감싸면, isReordering 토글이 켜져 있을 때만 활성화된 수평 드래그가 실시간 translationX로 반영됩니다.


마무리

1편의 제스처 감지 레이어와 2편의 상태 & 시각 피드백 레이어까지, DraggableListSample.kt의 드래그 정렬 구현을 통해 pointerInput의 주요 개념들이 어떻게 실제 문제를 해결하는지 살펴보았습니다.

핵심 개념 정리

개념역할이 샘플에서의 적용
pointerInput(key)key 변경 시 코루틴 재시작상태 객체를 key로 전달, 람다 캡처 문제 방지
awaitEachGesture제스처마다 자동 재시작커스텀 롱프레스 루프의 외곽 래퍼
withTimeoutOrNull타임아웃 기반 롱프레스 감지200ms 커스텀 롱프레스 구현
change.consume()이벤트 소비로 전파 차단드래그 중 부모 LazyColumn 스크롤 방지
graphicsLayerGPU 기반 시각적 이동드래그 아이템의 실시간 위치 반영 (성능 최적화)
zIndex렌더링 순서 제어드래그 중인 아이템이 다른 아이템 위에 표시
MutatorMutex코루틴 동시성 제어아이템 순서 교환과 스크롤 위치 조정의 충돌 방지
Animatable + spring물리 기반 애니메이션드래그 종료 후 원래 위치로 자연스러운 복귀
canDragItem 가드드래그 불가 아이템 필터링헤더가 태스크와 함께 스크롤되지만 드래그 대상에서는 제외

핵심 요약

  1. 기본 detectDragGesturesAfterLongPress로 시작하되, 롱프레스 시간이나 이벤트 소비 제어가 필요하다면 awaitEachGesture + withTimeoutOrNull로 직접 구현하는 것을 고려하세요.

  2. graphicsLayer { translationY/translationX }는 레이아웃을 건드리지 않고 GPU에서 시각적 이동을 처리하므로 드래그처럼 매 프레임 갱신이 필요한 경우 성능상 필수 선택입니다.

  3. change.consume()isConsumed 체크를 통한 이벤트 소비 제어는 중첩된 스크롤 컨테이너 안에서 드래그를 구현할 때 가장 중요한 핵심입니다.

  4. 하나의 LazyColumn 안에 헤더와 드래그 대상 아이템을 함께 두고 싶다면, 상태 객체에 canDragItem 같은 가드를 두어 히트 테스트와 타겟 탐색 모두에서 헤더를 걸러내면 깔끔하게 해결됩니다.

profile
코딩일기

0개의 댓글