[Android] DragGesture 동작 원리

Daemon·2025년 12월 21일

Android

목록 보기
10/13
post-thumbnail

들어가며

카드를 사용자가 드래그하면 실제 카드처럼 기울어지고, 뒤집으면 뒷면이 보이는 인터랙션을 구현했다. 포켓몬 카드의 홀로그래픽 효과를 CSS로 구현한 유명한 오픈소스가 있었는데 이미지와 글자가 있는 View를 3D 효과처럼 어떻게 세트로 움직이는 것인지 궁금했었다.

이번 글에서는 실제 구현해보면서 공부했던 Android의 DragGestures를 깊이 있게 알아본다.


1. 최종 구현 코드 미리보기

@Composable
fun Card(rank: LeagueRank, ...) {
    val rotationX = remember { Animatable(0f) }
    val rotationY = remember { Animatable(0f) }
    var isDragging by remember { mutableStateOf(false) }

    Box(
        modifier = Modifier
            .graphicsLayer {
                this.rotationX = rotationX.value
                this.rotationY = rotationY.value
                cameraDistance = 12f * density
            }
            .pointerInput(Unit) {
                detectDragGestures(
                    onDragStart = { isDragging = true },
                    onDragEnd = { isDragging = false },
                    onDrag = { change, dragAmount ->
                        change.consume()
                        val (x, y) = dragAmount
                        coroutineScope.launch {
                            rotationY.snapTo(rotationY.value + x * 0.05f)
                            rotationX.snapTo(rotationX.value - y * 0.05f)
                        }
                    }
                )
            }
    ) {
        if (isFlipped) CardBackSide() else CardFrontSide()
    }
}

이 코드가 어떻게 동작하는지, detectDragGestures 내부에서 무슨 일이 일어나는지 파헤쳐 보자.


2. detectDragGestures의 내부 구조

2.1 전체 흐름

Android 오픈소스를 보면 detectDragGestures 메서드가 kotlin으로 어떻게 구현되어있는지 보도록 하겠다.

suspend fun PointerInputScope.detectDragGestures(
    onDragStart: (Offset) -> Unit = {},
    onDragEnd: () -> Unit = {},
    onDragCancel: () -> Unit = {},
    onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit,
) {
    awaitEachGesture {
        // 1. 첫 터치 다운 대기
        val down = awaitFirstDown(requireUnconsumed = false)

        // 2. Touch Slop 감지 (드래그 의도 판별)
        val drag = awaitPointerSlopOrCancellation(down.id, down.type) { change, over ->
            change.consume()
            overSlop = over
        }

        // 3. 드래그 시작 및 연속 처리
        if (drag != null) {
            onDragStart.invoke(drag.position)
            onDrag(drag, overSlop)

            // 4. 드래그 루프
            val upEvent = drag(pointerId = drag.id, onDrag = { ... })

            if (upEvent == null) onDragCancel()
            else onDragEnd(upEvent)
        }
    }
}

2.2 awaitEachGesture: 제스처 생명주기 관리

awaitEachGesture 블록은 각 제스처 사이클을 독립적으로 처리하는 코루틴 빌더다.

하나의 제스처가 끝나면 자동으로 다음 제스처를 대기한다. 제스처 중 예외가 발생해도 다음 제스처에 영향이 없고, comosable이 recomposition되어도 안정적으로 동작한다.

각 제스처는 독립적으로 처리되고 완료되면 다음 제스처를 기다린다. Gesture #1이 완료/취소되면 Gesture #2를 기다리고, 그게 완료되면 Gesture #3을 기다리는 식이다.


3. Touch Slop: 드래그 의도 감지의 핵심

3.1 Touch Slop이란?

사용자가 화면을 터치할 때, 단순 "탭"인지 화면을 잡아당기는 "드래그"인지 구분해야 한다. 손가락이 조금이라도 움직였다고 바로 드래그로 처리하면 탭 동작이 불가능해진다.

Touch Slop = 드래그로 인식하기 위한 최소 이동 거리 (보통 8~18dp)

터치 시작점을 중심으로 Slop Zone이라는 영역이 있다. 이 영역 내에서의 이동은 "탭"으로 처리되고 드래그가 시작되지 않는다. 이 영역을 벗어나면 그제야 드래그가 시작된다.

3.2 TouchSlopDetector 클래스 분석

internal class TouchSlopDetector(
    var orientation: Orientation? = null,
    initialPositionChange: Offset = Offset.Zero,
) {
    // 누적 이동량
    private var totalPositionChange: Offset = initialPositionChange

    fun addPositions(
        currentPosition: Offset, 
        previousPosition: Offset, 
        touchSlop: Float
    ): Offset {
        // 1. 이동량 누적
        val positionChange = currentPosition - previousPosition
        totalPositionChange += positionChange

        // 2. 방향에 따른 이동 거리 계산
        val inDirection = if (orientation == null) {
            totalPositionChange.getDistance()  // 전방향: 유클리드 거리
        } else {
            totalPositionChange.mainAxis().absoluteValue  // 단일 축
        }

        // 3. Slop 초과 여부 판단
        val hasCrossedSlop = inDirection >= touchSlop

        return if (hasCrossedSlop) {
            calculatePostSlopOffset(touchSlop)  // Slop 초과분 반환
        } else {
            Offset.Unspecified  // 아직 드래그 아님
        }
    }
}

핵심 로직은 이렇다:

  1. 매 포인터 이벤트마다 totalPositionChange에 이동량을 누적한다
  2. orientation에 따라 거리 계산 방식을 결정한다
    • null이면 전방향 (피타고라스 정리로 거리 계산)
    • Horizontal이면 x축만
    • Vertical이면 y축만
  3. touchSlop 임계값을 초과하면 초과분(postSlopOffset)을 반환한다

3.3 Post-Slop Offset 계산

Touch slop을 넘은 순간, 정확히 slop만큼 뺀 나머지를 첫 드래그 이벤트로 전달한다.

private fun calculatePostSlopOffset(touchSlop: Float): Offset {
    return if (orientation == null) {
        // 전방향: 벡터 방향 유지하면서 slop만큼 빼기
        val touchSlopOffset = totalPositionChange /
            totalPositionChange.getDistance() * touchSlop
        totalPositionChange - touchSlopOffset
    } else {
        // 단일 축: 해당 축에서 slop 빼기
        val finalMainAxisChange = totalPositionChange.mainAxis() -
            (sign(totalPositionChange.mainAxis()) * touchSlop)
        ...
    }
}

예를 들어 touchSlop이 18dp이고 실제 이동이 (24, 0)이라면:

  • 시작점부터 18dp까지는 무시된다 (slop)
  • 18dp에서 24dp까지의 6dp만 postSlop으로 계산된다
  • 첫 onDrag 호출 시 dragAmount = (6, 0)이 전달된다

이렇게 하면 드래그 시작하자마자 반응하는 것이 아니라 어떻게 보면 탭 동작이랑 분리하기 위해서 존재하는 것 같고 탭 동작과 차별을 두어서 자연스럽게 움직임이 시작된다.


4. 드래그 이벤트 루프

4.1 awaitDragOrUp 함수

Touch slop을 통과하면, 이후 모든 포인터 이동 이벤트를 처리한다.

private suspend inline fun AwaitPointerEventScope.awaitDragOrUp(
    pointerId: PointerId,
    hasDragged: (PointerInputChange) -> Boolean,
): PointerInputChange? {
    var pointer = pointerId
    while (true) {
        val event = awaitPointerEvent()
        val dragEvent = event.changes.fastFirstOrNull { it.id == pointer }
            ?: return null

        if (dragEvent.changedToUpIgnoreConsumed()) {
            // 손가락을 뗐을 때
            val otherDown = event.changes.fastFirstOrNull { it.pressed }
            if (otherDown == null) {
                return dragEvent  // 마지막 손가락 → 제스처 종료
            } else {
                pointer = otherDown.id  // 다른 손가락으로 제어점 전환
            }
        } else if (hasDragged(dragEvent)) {
            return dragEvent  // 드래그 이벤트 발생
        }
    }
}

멀티터치 처리가 흥미롭다. 현재 추적 중인 손가락이 떼어지면 다른 손가락으로 자동 전환된다. 모든 손가락이 떼어지면 그제야 제스처가 종료된다.

4.2 positionChange()로 이동량 추출

PointerInputChange에서 실제 이동량을 얻는 방법은 간단하다.

fun PointerInputChange.positionChange(): Offset =
    position - previousPosition

이전 이벤트가 (100, 200)이고 현재 이벤트가 (108, 195)라면:

positionChange = (108 - 100, 195 - 200) = (8, -5)

x 이동량은 8, y 이동량은 -5다.


5. 드래그량을 3D 회전으로 변환

5.1 좌표계 이해

Y축 회전(rotationY)은 위를 향하고, X축은 수평 드래그 방향이다. X축 회전(rotationX)은 아래를 향하고, Y축은 수직 드래그 방향이다.

수평 드래그를 하면 카드가 Y축을 중심으로 회전하면서 좌우로 기울어진다. 수직 드래그를 하면 카드가 X축을 중심으로 회전하면서 상하로 기울어진다.

5.2 변환 수식

onDrag = { change, dragAmount ->
    val (x, y) = dragAmount  // 픽셀 단위

    // 수평 드래그 → Y축 회전 (카드가 좌우로 기울어짐)
    rotationY.snapTo(rotationY.value + x * 0.05f)

    // 수직 드래그 → X축 회전 (카드가 상하로 기울어짐, 부호 반전)
    rotationX.snapTo(rotationX.value - y * 0.05f)
}

왜 0.05f를 곱할까?

픽셀을 각도로 변환하는 비율이다. 값이 크면 민감하게 반응한다. 조금만 움직여도 많이 회전한다. 값이 작으면 둔감하게 반응한다. 많이 움직여야 조금 회전한다. 0.8f는 과도한 움직임 때문에 강하게 드래그할 경우 뒷면도 볼 수 있는 정도라서 대폭 감소시켰다.

왜 Y 드래그에 -를 붙일까?

화면 좌표계는 아래로 갈수록 Y가 증가한다. 하지만 현실세계에서는 그 반대로 흘러야한다. 아래로 드래그하면 카드 상단이 앞으로 기울어져야하므로 부호를 반전시켜서 자연스러운 물리적 느낌을 구현한 것이다.

5.3 graphicsLayer의 cameraDistance

.graphicsLayer {
    this.rotationX = rotationX.value
    this.rotationY = rotationY.value
    cameraDistance = 12f * density
}

cameraDistance는 가상 카메라와 화면 사이의 거리다.

값이 작으면 (예: 4f) 카메라가 가까이 있어서 과장된 원근감이 생긴다. 회전 시 왜곡이 심하다. 값이 크면 (예: 16f) 카메라가 멀리 있어서 자연스러운 원근감이 생긴다. 회전 시 왜곡이 적다.

12f * density는 다양한 화면 밀도에서 일관된 경험을 제공하기 위한 값이다.


6. 카드 뒤집기 판정

6.1 앞면/뒷면 판정 로직

val isFlipped = abs(rotationY.value % 360).let { angle ->
    angle > 90 && angle < 270
}

0~90도 구간은 앞면이 보인다. 90~270도 구간은 뒷면이 보인다. 270~360도 구간은 다시 앞면이 보인다.

0도일 때는 앞면이 정면으로 보이고, 90도로 회전하면 측면이 보이고, 180도로 회전하면 뒷면이 정면으로 보인다.

6.2 뒷면 보정

카드 뒷면은 거울처럼 반전되어 보이므로 추가 회전이 필요하다.

@Composable
private fun LeagueRankCardBackSide() {
    Box(
        modifier = Modifier
            .graphicsLayer {
                rotationY = 180f  // 뒷면 내용을 180° 회전
            }
    ) {
        // 뒷면 컨텐츠
    }
}

7. 드래그 종료 시 복원 애니메이션

LaunchedEffect(isDragging) {
    if (!isDragging) {
        launch {
            rotationX.animateTo(0f, tween(1000, easing = LinearOutSlowInEasing))
        }
        launch {
            rotationY.animateTo(0f, tween(1000, easing = LinearOutSlowInEasing))
        }
    }
}

LinearOutSlowInEasing은 빠르게 시작해서 천천히 도착하는 비선형 보간이 적용된 곡선이다. 손을 떼면 카드가 탄성이 있는 것처럼 원래 위치로 돌아오는 느낌을 준다.


8. 정리: 전체 데이터 흐름

  1. 사용자가 터치하면 awaitFirstDown()이 첫 터치 다운을 감지한다. 그러면 TouchSlopDetector가 이동량을 누적하고 touchSlop(18dp) 초과 여부를 판단한다. 초과하면 postSlopOffset을 계산한다.

  2. slop을 초과하면 onDragStart 콜백이 호출되어 isDragging이 true가 된다. 그 다음 드래그 루프(awaitDragOrUp)에 진입한다.

  3. onDrag(change, dragAmount)가 계속 호출되면서 dragAmount의 (x, y) 픽셀 값으로 rotationY += x 0.05f, rotationX -= y 0.05f가 계산된다. change.consume()으로 이벤트를 소비한다. 계속 드래그 중이면 이 루프가 반복된다.

  4. 손가락을 떼면 onDragEnd 콜백이 호출되어 isDragging이 false가 되고 복원 애니메이션이 트리거된다.

  5. 마지막으로 graphicsLayer가 rotationX, rotationY를 적용하고 cameraDistance로 원근감을 조절한다. isFlipped를 계산해서 앞면/뒷면을 분기한다.


마치며

Compose의 제스처 시스템은 저수준의 포인터 이벤트를 고수준의 의미 있는 제스처로 변환하는 정교한 구조를 갖추고 있다. 이를 graphicsLayer의 3D 변환과 결합하면, 물리적으로 자연스러운 카드 인터랙션을 구현할 수 있다. 카드 애니메이션이 돋보이는 토스나 다른 앱을 보면 금속 반사 효과도 있어서 AGSL 셰이더를 통해서 적용하려고 했는데 빌드 버전이 TIRAMISU 이상만 작동해서 아쉬웠다.

0개의 댓글