카드를 사용자가 드래그하면 실제 카드처럼 기울어지고, 뒤집으면 뒷면이 보이는 인터랙션을 구현했다. 포켓몬 카드의 홀로그래픽 효과를 CSS로 구현한 유명한 오픈소스가 있었는데 이미지와 글자가 있는 View를 3D 효과처럼 어떻게 세트로 움직이는 것인지 궁금했었다.
이번 글에서는 실제 구현해보면서 공부했던 Android의 DragGestures를 깊이 있게 알아본다.
@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 내부에서 무슨 일이 일어나는지 파헤쳐 보자.
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)
}
}
}
awaitEachGesture 블록은 각 제스처 사이클을 독립적으로 처리하는 코루틴 빌더다.
하나의 제스처가 끝나면 자동으로 다음 제스처를 대기한다. 제스처 중 예외가 발생해도 다음 제스처에 영향이 없고, comosable이 recomposition되어도 안정적으로 동작한다.
각 제스처는 독립적으로 처리되고 완료되면 다음 제스처를 기다린다. Gesture #1이 완료/취소되면 Gesture #2를 기다리고, 그게 완료되면 Gesture #3을 기다리는 식이다.
사용자가 화면을 터치할 때, 단순 "탭"인지 화면을 잡아당기는 "드래그"인지 구분해야 한다. 손가락이 조금이라도 움직였다고 바로 드래그로 처리하면 탭 동작이 불가능해진다.
Touch Slop = 드래그로 인식하기 위한 최소 이동 거리 (보통 8~18dp)
터치 시작점을 중심으로 Slop Zone이라는 영역이 있다. 이 영역 내에서의 이동은 "탭"으로 처리되고 드래그가 시작되지 않는다. 이 영역을 벗어나면 그제야 드래그가 시작된다.
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 // 아직 드래그 아님
}
}
}
핵심 로직은 이렇다:
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)이라면:
이렇게 하면 드래그 시작하자마자 반응하는 것이 아니라 어떻게 보면 탭 동작이랑 분리하기 위해서 존재하는 것 같고 탭 동작과 차별을 두어서 자연스럽게 움직임이 시작된다.
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 // 드래그 이벤트 발생
}
}
}
멀티터치 처리가 흥미롭다. 현재 추적 중인 손가락이 떼어지면 다른 손가락으로 자동 전환된다. 모든 손가락이 떼어지면 그제야 제스처가 종료된다.
PointerInputChange에서 실제 이동량을 얻는 방법은 간단하다.
fun PointerInputChange.positionChange(): Offset =
position - previousPosition
이전 이벤트가 (100, 200)이고 현재 이벤트가 (108, 195)라면:
positionChange = (108 - 100, 195 - 200) = (8, -5)
x 이동량은 8, y 이동량은 -5다.
Y축 회전(rotationY)은 위를 향하고, X축은 수평 드래그 방향이다. X축 회전(rotationX)은 아래를 향하고, Y축은 수직 드래그 방향이다.
수평 드래그를 하면 카드가 Y축을 중심으로 회전하면서 좌우로 기울어진다. 수직 드래그를 하면 카드가 X축을 중심으로 회전하면서 상하로 기울어진다.
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가 증가한다. 하지만 현실세계에서는 그 반대로 흘러야한다. 아래로 드래그하면 카드 상단이 앞으로 기울어져야하므로 부호를 반전시켜서 자연스러운 물리적 느낌을 구현한 것이다.
.graphicsLayer {
this.rotationX = rotationX.value
this.rotationY = rotationY.value
cameraDistance = 12f * density
}
cameraDistance는 가상 카메라와 화면 사이의 거리다.
값이 작으면 (예: 4f) 카메라가 가까이 있어서 과장된 원근감이 생긴다. 회전 시 왜곡이 심하다. 값이 크면 (예: 16f) 카메라가 멀리 있어서 자연스러운 원근감이 생긴다. 회전 시 왜곡이 적다.
12f * density는 다양한 화면 밀도에서 일관된 경험을 제공하기 위한 값이다.
val isFlipped = abs(rotationY.value % 360).let { angle ->
angle > 90 && angle < 270
}
0~90도 구간은 앞면이 보인다. 90~270도 구간은 뒷면이 보인다. 270~360도 구간은 다시 앞면이 보인다.
0도일 때는 앞면이 정면으로 보이고, 90도로 회전하면 측면이 보이고, 180도로 회전하면 뒷면이 정면으로 보인다.
카드 뒷면은 거울처럼 반전되어 보이므로 추가 회전이 필요하다.
@Composable
private fun LeagueRankCardBackSide() {
Box(
modifier = Modifier
.graphicsLayer {
rotationY = 180f // 뒷면 내용을 180° 회전
}
) {
// 뒷면 컨텐츠
}
}
LaunchedEffect(isDragging) {
if (!isDragging) {
launch {
rotationX.animateTo(0f, tween(1000, easing = LinearOutSlowInEasing))
}
launch {
rotationY.animateTo(0f, tween(1000, easing = LinearOutSlowInEasing))
}
}
}
LinearOutSlowInEasing은 빠르게 시작해서 천천히 도착하는 비선형 보간이 적용된 곡선이다. 손을 떼면 카드가 탄성이 있는 것처럼 원래 위치로 돌아오는 느낌을 준다.
사용자가 터치하면 awaitFirstDown()이 첫 터치 다운을 감지한다. 그러면 TouchSlopDetector가 이동량을 누적하고 touchSlop(18dp) 초과 여부를 판단한다. 초과하면 postSlopOffset을 계산한다.
slop을 초과하면 onDragStart 콜백이 호출되어 isDragging이 true가 된다. 그 다음 드래그 루프(awaitDragOrUp)에 진입한다.
onDrag(change, dragAmount)가 계속 호출되면서 dragAmount의 (x, y) 픽셀 값으로 rotationY += x 0.05f, rotationX -= y 0.05f가 계산된다. change.consume()으로 이벤트를 소비한다. 계속 드래그 중이면 이 루프가 반복된다.
손가락을 떼면 onDragEnd 콜백이 호출되어 isDragging이 false가 되고 복원 애니메이션이 트리거된다.
마지막으로 graphicsLayer가 rotationX, rotationY를 적용하고 cameraDistance로 원근감을 조절한다. isFlipped를 계산해서 앞면/뒷면을 분기한다.
Compose의 제스처 시스템은 저수준의 포인터 이벤트를 고수준의 의미 있는 제스처로 변환하는 정교한 구조를 갖추고 있다. 이를 graphicsLayer의 3D 변환과 결합하면, 물리적으로 자연스러운 카드 인터랙션을 구현할 수 있다. 카드 애니메이션이 돋보이는 토스나 다른 앱을 보면 금속 반사 효과도 있어서 AGSL 셰이더를 통해서 적용하려고 했는데 빌드 버전이 TIRAMISU 이상만 작동해서 아쉬웠다.