Card Swipe 구현하기

이윤설·2025년 3월 2일
0

안드로이드 연구소

목록 보기
29/33

카드 스와이프 애니메이션 구현하기

우리가 만들 것

  • 오른쪽으로 스와이프하면 "예약"
  • 왼쪽으로 스와이프하면 "건너뛰기"
  • 조금만 스와이프하고 놓으면 "원위치로 돌아오는 통통 튀는 효과"

를 구현해보자.

핵심 개념

1. 카드의 상태 기억하기

// 현재 보여지는 식당 인덱스
var currentIndex by remember { mutableStateOf(0) }

// 애니메이션 상태
val offsetX = remember { Animatable(0f) }
val offsetY = remember { Animatable(0f) }
val rotation = remember { Animatable(0f) }

// 드래그 상태
var isDragging by remember { mutableStateOf(false) }

쉬운 설명:

  • currentIndex: 지금 보여주는 카드가 몇 번째인지 기억한다.
  • offsetXoffsetY: 카드가 화면에서 얼마나 움직였는지 위치를 기억한다.
  • rotation: 카드가 얼마나 회전했는지 각도를 기억한다.
  • isDragging: 현재 사용자가 카드를 드래그하고 있는지 여부를 기억한다.

Animatable은 애니메이션이 가능한 값을 담는 특별한 상자라고 생각하면 된다!

2. 손가락 움직임 감지하기

.pointerInput(Unit) {
    detectDragGestures(
        onDragStart = { isDragging = true },
        onDragEnd = {
            isDragging = false
            
            // 많이 당겼으면 카드 날려버리기
            when {
                offsetX.value > 300 -> {
                    // 오른쪽으로 스와이프 (예약)
                    coroutineScope.launch {
                        offsetX.animateTo(
                            1000f,
                            animationSpec = tween(300)
                        )
                        // 다음 카드로 이동
                        currentIndex++
                        offsetX.snapTo(0f)
                        offsetY.snapTo(0f)
                        rotation.snapTo(0f)
                    }
                }
                // 다른 코드는 생략...
            }
        },
        onDrag = { change, dragAmount ->
            change.consume()
            coroutineScope.launch {
                offsetX.snapTo(offsetX.value + dragAmount.x)
                offsetY.snapTo(offsetY.value + dragAmount.y)
                rotation.snapTo(offsetX.value / 40)
            }
        }
    )
}

쉬운 설명:

  • detectDragGestures: 손가락으로 화면을 드래그할 때 움직임을 감지하는 도구
  • onDragStart: 드래그를 시작했을 때 실행되는 코드
  • onDragEnd: 손가락을 떼어 드래그가 끝났을 때 실행되는 코드
  • onDrag: 손가락을 움직이는 동안 계속 실행되는 코드

중요 포인트:

  • offsetX.value > 300: 카드를 오른쪽으로 300픽셀 이상 드래그했으면 예약으로 처리
  • offsetX.value < -300: 카드를 왼쪽으로 300픽셀 이상 드래그했으면 건너뛰기로 처리
  • 그 외의 경우: 카드를 원래 위치로 돌려놓기

consume을 사용하는 이유
touch.consume()은 Jetpack Compose에서 터치 이벤트의 전파를 제어하는 중요한 함수다.

터치 이벤트는 기본적으로 자식 컴포넌트에서 부모 컴포넌트로 전파된다.
이를 '이벤트 버블링'이라고 한다.
여러 컴포넌트가 중첩되어 있을 때, 하나의 터치 이벤트는 여러 컴포넌트에 영향을 줄 수 있다.

change.consume()은 "이 이벤트는 여기서 처리했으니 더 이상 전파하지 마세요"라고 시스템에 알려주는 역할을 하는 것이다. 이것은 여러 컴포넌트가 중첩된 UI에서 특히 중요하다!

예를 들어, 스크롤 가능한 화면 위에 스와이프 가능한 카드가 있다고 가정해보자.

  • change.consume()을 사용하면: 카드를 스와이프할 때 그 이벤트는 카드에서만 처리되고, 아래 있는 스크롤 화면으로 전달되지 않음

  • change.consume()을 사용하지 않으면: 카드를 스와이프할 때 그 이벤트가 카드에서 처리된 후 스크롤 화면으로도 전달되어, 카드가 스와이프되면서 화면도 함께 스크롤될 수 있음

따라서 change.consume()은 사용자 경험을 명확하고 예측 가능하게 만드는 데 필수적이다. 특히 스와이프, 드래그, 줌 같은 복잡한 제스처가 포함된 UI에서 더욱 중요하다!!!!

3. 애니메이션 효과 만들기

1) 드래그하는 동안 실시간으로 카드 움직이기

offsetX.snapTo(offsetX.value + dragAmount.x)
offsetY.snapTo(offsetY.value + dragAmount.y)
rotation.snapTo(offsetX.value / 40)

쉬운 설명:

  • snapTo: 값을 즉시 변경한다.
  • dragAmount.x: 손가락이 x축(좌우)으로 얼마나 움직였는지 알려준다.
  • offsetX.value / 40: 카드가 오른쪽으로 갈수록 시계방향, 왼쪽으로 갈수록 반시계방향으로 회전하게 한다.

2) 카드를 완전히 스와이프할 때 부드럽게 날아가게 하기

offsetX.animateTo(
    1000f,
    animationSpec = tween(300)
)

쉬운 설명:

  • animateTo: 지정된 값으로 부드럽게 애니메이션 효과를 주며 변경한다.
  • 1000f: 화면 밖으로 멀리 날아가게 한다.
  • tween(300): 300밀리초(0.3초) 동안 부드럽게 움직이게 한다.
    • tween이란? 시작점에서 끝점까지 일정한 속도로 변화시키는 방법

3) 조금만 스와이프하고 놓았을 때 통통 튀면서 원위치로 돌아오기

offsetX.animateTo(
    0f,
    animationSpec = SpringSpec(
        dampingRatio = Spring.DampingRatioMediumBouncy,
        stiffness = Spring.StiffnessLow
    )
)

쉬운 설명:

  • 0f: 원래 위치(가운데)로 돌아가라는 의미예요
  • SpringSpec: 실제 용수철(스프링)처럼 통통 튀는 효과를 내는 애니메이션이다.
    • SpringSpec이란? 실제 물리적인 스프링(용수철)의 움직임을 모방한 애니메이션이다. 물체가 제자리로 돌아올 때 그냥 딱 돌아오는 게 아니라 조금 지나쳤다가 돌아오는 자연스러운 움직임을 만들어준다.
  • dampingRatio: 얼마나 많이 통통 튈지 결정해요 (값이 작을수록 더 많이 통통 튄다)
  • stiffness: 얼마나 빨리 움직일지 결정해요 (값이 클수록 더 빨리 움직인다)

4. 애니메이션 값을 화면에 적용하기

Box(
    modifier = Modifier
        .offset {
            IntOffset(
                offsetX.value.roundToInt(),
                offsetY.value.roundToInt()
            )
        }
        .rotate(rotation.value)
) {
    // 카드 내용
}

쉬운 설명:

  • offset: 카드의 위치를 이동시킨다
  • IntOffset: 정확히 몇 픽셀 움직일지 지정한다
  • rotate: 카드를 회전시킨다
  • 이렇게 하면 우리가 계산한 애니메이션 값대로 카드가 화면에서 움직인다.

5. 스와이프 방향 표시하기

// 예약 표시 (오른쪽)
if (offsetX.value > 80) {
    Box(
        modifier = Modifier
            .alpha(minOf(1f, offsetX.value / 200))
    ) {
        Text(
            text = "예약",
            color = Color.Green
        )
    }
}

쉬운 설명:

  • if (offsetX.value > 80): 오른쪽으로 80픽셀 이상 스와이프했을 때만 표시한다.
  • alpha: 투명도를 조절해요. 더 많이 스와이프할수록 더 선명하게 보인다.

애니메이션 정리

  1. Animatable - 시간에 따라 변하는 애니메이션 가능한 값을 관리하는 상태 홀

  2. detectDragGestures - 드래그 제스처를 감지하고 처리하는 함수

  3. animateTo() - 지정된 값으로 부드럽게 애니메이션화한다.

  4. snapTo() - 애니메이션 없이 값을 즉시 변경한다(드래그 중 실시간 반응에 사용).

  5. coroutineScope.launch - 그냥 코루틴 스코프

  6. offset { IntOffset(x, y) } - 컴포저블의 위치를 픽셀 단위로 이동시킨다.

  7. rotate() - 컴포저블을 지정된 각도만큼 회전시킨다.

  8. remember { Animatable(0f) } - 리컴포지션 과정에서도 애니메이션 상태를 유지한다.

  9. SpringSpec - 스프링(용수철) 물리를 시뮬레이션하는 애니메이션 명세

  10. tween() - 시작부터 끝까지 일정한 패턴으로 변화하는 애니메이션 명세

  11. change.consume() - 이벤트가 더 이상 전파되지 않도록 소비한다.

  12. alpha() - 컴포저블의 투명도를 조절하여 페이드 인/아웃 효과를 만든다.

  13. dragAmount - 마지막 이벤트 이후 발생한 드래그의 x, y 변화량

  14. dampingRatio - 스프링 애니메이션의 진동 감쇠 정도를 조절합니다.

  15. stiffness - 스프링 애니메이션의 경직도/강성을 조절합니다.

코드

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MaterialTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = Color.White
                ) {
                    SwipeCardsScreen()
                }
            }
        }
    }
}

// 간단한 식당 데이터 클래스
data class SimpleRestaurant(
    val id: Int,
    val name: String,
    val cuisine: String,
    val rating: Float
)

// 샘플 식당 목록
val sampleRestaurants = listOf(
    SimpleRestaurant(1, "시칠리 하우스", "이탈리안", 4.5f),
    SimpleRestaurant(2, "서울 갈비", "한식", 4.8f),
    SimpleRestaurant(3, "스시 익스프레스", "일식", 4.3f),
    SimpleRestaurant(4, "커리 가든", "인도식", 4.6f),
    SimpleRestaurant(5, "버거 매니아", "양식", 4.2f)
)

@Composable
fun SwipeCardsScreen() {
    // 현재 보여지는 식당 인덱스
    var currentIndex by remember { mutableStateOf(0) }

    // 애니메이션 상태
    val offsetX = remember { Animatable(0f) }
    val offsetY = remember { Animatable(0f) }
    val rotation = remember { Animatable(0f) }

    // 드래그 상태
    var isDragging by remember { mutableStateOf(false) }

    // 코루틴 스코프
    val coroutineScope = rememberCoroutineScope()

    Box(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        contentAlignment = Alignment.Center
    ) {
        // 하단 식당 카드 (다음 카드 미리보기)
        if (currentIndex < sampleRestaurants.size - 1) {
            RestaurantCardSimple(
                restaurant = sampleRestaurants[currentIndex + 1],
                backgroundColor = Color(0xFFF5F5F5)
            )
        }

        // 현재 식당 카드 (스와이프 가능)
        if (currentIndex < sampleRestaurants.size) {
            Box(
                modifier = Modifier
                    .offset {
                        IntOffset(
                            offsetX.value.roundToInt(),
                            offsetY.value.roundToInt()
                        )
                    }
                    .rotate(rotation.value)
                    .pointerInput(Unit) {
                        detectDragGestures(
                            onDragStart = { isDragging = true },
                            onDragEnd = {
                                isDragging = false

                                // 임계값을 넘으면 카드 스와이프 완료
                                when {
                                    offsetX.value > 300 -> {
                                        // 오른쪽으로 스와이프 (예약)
                                        coroutineScope.launch {
                                            offsetX.animateTo(
                                                1000f,
                                                animationSpec = tween(300)
                                            )
                                            // 다음 카드로 이동
                                            currentIndex++
                                            offsetX.snapTo(0f)
                                            offsetY.snapTo(0f)
                                            rotation.snapTo(0f)
                                        }
                                    }
                                    offsetX.value < -300 -> {
                                        // 왼쪽으로 스와이프 (건너뛰기)
                                        coroutineScope.launch {
                                            offsetX.animateTo(
                                                -1000f,
                                                animationSpec = tween(300)
                                            )
                                            // 다음 카드로 이동
                                            currentIndex++
                                            offsetX.snapTo(0f)
                                            offsetY.snapTo(0f)
                                            rotation.snapTo(0f)
                                        }
                                    }
                                    else -> {
                                        // 원위치로 복귀
                                        coroutineScope.launch {
                                            offsetX.animateTo(
                                                0f,
                                                animationSpec = SpringSpec(
                                                    dampingRatio = Spring.DampingRatioMediumBouncy,
                                                    stiffness = Spring.StiffnessLow
                                                )
                                            )
                                            offsetY.animateTo(
                                                0f,
                                                animationSpec = SpringSpec(
                                                    dampingRatio = Spring.DampingRatioMediumBouncy,
                                                    stiffness = Spring.StiffnessLow
                                                )
                                            )
                                            rotation.animateTo(
                                                0f,
                                                animationSpec = SpringSpec(
                                                    dampingRatio = Spring.DampingRatioMediumBouncy,
                                                    stiffness = Spring.StiffnessLow
                                                )
                                            )
                                        }
                                    }
                                }
                            },
                            onDrag = { change, dragAmount ->
                                change.consume()
                                coroutineScope.launch {
                                    offsetX.snapTo(offsetX.value + dragAmount.x)
                                    offsetY.snapTo(offsetY.value + dragAmount.y)
                                    // X 위치에 따라 회전 (드래그 중에 회전 효과)
                                    rotation.snapTo(offsetX.value / 40)
                                }
                            }
                        )
                    }
            ) {
                RestaurantCardSimple(
                    restaurant = sampleRestaurants[currentIndex],
                    backgroundColor = Color.White
                )

                // 스와이프 방향 표시기
                if (isDragging) {
                    // 예약 표시 (오른쪽)
                    if (offsetX.value > 80) {
                        Box(
                            modifier = Modifier
                                .align(Alignment.TopStart)
                                .padding(16.dp)
                                .rotate(-30f)
                                .border(
                                    width = 2.dp,
                                    color = Color.Green,
                                    shape = RoundedCornerShape(8.dp)
                                )
                                .padding(8.dp)
                                .alpha(minOf(1f, offsetX.value / 200))
                        ) {
                            Text(
                                text = "예약",
                                color = Color.Green,
                                fontWeight = FontWeight.Bold
                            )
                        }
                    }

                    // 건너뛰기 표시 (왼쪽)
                    if (offsetX.value < -80) {
                        Box(
                            modifier = Modifier
                                .align(Alignment.TopEnd)
                                .padding(16.dp)
                                .rotate(30f)
                                .border(
                                    width = 2.dp,
                                    color = Color.Red,
                                    shape = RoundedCornerShape(8.dp)
                                )
                                .padding(8.dp)
                                .alpha(minOf(1f, offsetX.value.absoluteValue / 200))
                        ) {
                            Text(
                                text = "건너뛰기",
                                color = Color.Red,
                                fontWeight = FontWeight.Bold
                            )
                        }
                    }
                }
            }
        } else {
            // 모든 카드를 다 본 경우
            Column(
                horizontalAlignment = Alignment.CenterHorizontally,
                verticalArrangement = Arrangement.Center
            ) {
                Text(
                    text = "더 이상 표시할 식당이 없습니다",
                    fontSize = 18.sp,
                    fontWeight = FontWeight.Medium
                )
            }
        }
    }
}

@Composable
fun RestaurantCardSimple(
    restaurant: SimpleRestaurant,
    backgroundColor: Color
) {
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(8.dp),
        shape = RoundedCornerShape(16.dp),
        elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
        colors = CardDefaults.cardColors(containerColor = backgroundColor)
    ) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp)
        ) {
            // 식당 이름
            Text(
                text = restaurant.name,
                fontSize = 22.sp,
                fontWeight = FontWeight.Bold
            )

            // 요리 종류
            Text(
                text = restaurant.cuisine,
                fontSize = 16.sp,
                color = Color.Gray,
                modifier = Modifier.padding(vertical = 4.dp)
            )

            // 평점
            Text(
                text = "평점: ${restaurant.rating}",
                fontSize = 14.sp
            )

            // 간단한 설명
            Text(
                text = "드래그하여 예약 또는 건너뛰기를 선택하세요",
                fontSize = 14.sp,
                color = Color.Gray,
                modifier = Modifier.padding(top = 12.dp)
            )
        }
    }
}
profile
화려한 외면이 아닌 단단한 내면

0개의 댓글

관련 채용 정보