Flipping Card 구현하기

이윤설·2025년 3월 2일
0

안드로이드 연구소

목록 보기
28/33

Flipping Card(단어장) 구현하기

1. 카드가 뒤집히는 원리 이해하기

실제 카드를 손으로 뒤집는 걸 상상해보자!
카드는 어떻게 움직일까? 바로 세로축(Y축)을 중심으로 회전한다.
이 원리를 그대로 앱에 적용시키면 된당

카드가 뒤집히는 동안 일어나는 일:
1. 카드가 Y축을 중심으로 회전한다. (0도에서 180도까지)
2. 앞면이 서서히 사라지고, 뒷면이 서서히 나타난다.
3. 이 모든 과정이 부드럽게 진행된다.

2. 카드의 상태 관리하기

먼저 카드가 현재 앞면인지 뒷면인지 기억해야 한다.

// 카드가 뒤집혔는지 상태 추적
var isFlipped by remember { mutableStateOf(false) }
  • isFlipped: 카드가 뒤집혔는지 알려주는 변수
  • false면 앞면, true면 뒷면을 의미
  • remember: 화면이 다시 그려져도 이 값을 기억하라는 명령
  • mutableStateOf: 이 값이 변할 수 있고, 값이 변하면 화면을 다시 그려야 한다는 의미

3. 회전 애니메이션 만들기

이제 카드를 부드럽게 회전시키는 애니메이션을 만들어보자.

// 애니메이션 회전 값
val rotation by animateFloatAsState(
    targetValue = if (isFlipped) 180f else 0f,
    animationSpec = tween(
        durationMillis = 400,
        easing = FastOutSlowInEasing
    ),
    label = "rotation"
)
  • animateFloatAsState: 숫자값(float)을 부드럽게 변화시켜주는 애니메이션 도구
  • targetValue: 최종 목표값. 앞면이면 0도, 뒷면이면 180도임.
  • tween: 애니메이션의 속도와 움직임을 조절하는 설정
    • tween이란? 시작점에서 끝점까지 일정한 패턴으로 값을 변화시키는 방법이다. 마치 A 지점에서 B 지점까지 특정 시간 동안 이동하는 것과 같다.
  • durationMillis: 애니메이션 지속 시간 (0.4초)
  • FastOutSlowInEasing: 애니메이션의 속도 변화 패턴이다.
    처음엔 빠르게 시작했다가 끝에 가서 천천히 끝나는 자연스러운 움직임을 만들어준다.

4. 페이드 인/아웃 효과 추가하기

카드가 회전하는 동안 앞면이 서서히 사라지고 뒷면이 서서히 나타나도록 하자.

// 앞면/뒷면 가시성
val frontAlpha by animateFloatAsState(
    targetValue = if (isFlipped) 0f else 1f,
    animationSpec = tween(300),
    label = "frontAlpha"
)

val backAlpha by animateFloatAsState(
    targetValue = if (isFlipped) 1f else 0f,
    animationSpec = tween(300),
    label = "backAlpha"
)
  • alpha: 투명도를 의미한다. 0이면 완전히 투명(안 보임), 1이면 완전히 불투명(잘 보임)이다.
  • 앞면은 카드가 뒤집히면(isFlippedtrue) 투명해지고(0), 앞면이면 불투명해진다(1)
  • 뒷면은 그 반대로 작동한다
  • tween(300): 이 투명도 변화가 0.3초 동안 일어난다

5. 카드에 애니메이션 적용하기

이제 준비한 애니메이션 값들을 실제 카드에 적용하자..

Card(
    modifier = Modifier
        .fillMaxWidth()
        .height(150.dp)
        .graphicsLayer {
            rotationY = rotation
            cameraDistance = 12f * density
        }
        .clickable { isFlipped = !isFlipped },
    // ... 카드 스타일 설정
) {
    // 카드 내용
}
  • graphicsLayer: 특별한 그래픽 효과(회전, 크기 조절 등)를 적용할 수 있게 해주는 도구
  • rotationY: Y축(세로축)을 중심으로 회전시키는 속성
    • rotationY란? 물체를 세로축을 중심으로 회전시키는 것이다. 마치 책의 페이지를 넘기는 것처럼 왼쪽에서 오른쪽으로(또는 그 반대로) 회전한다.
  • cameraDistance: 화면과 카드 사이의 가상 거리. 이 값이 클수록 3D 효과가 더 자연스러워 보인다.
  • density: 화면 밀도. 다양한 기기에서 일관된 모습을 보여주기 위해 곱해준다.
  • clickable: 카드를 탭할 수 있게 해주고, 탭했을 때 isFlipped 값을 반대로 바꿔준다.

6. 앞면과 뒷면 내용 보여주기

이제 카드의 앞면과 뒷면에 내용을 넣자.

// 앞면 (단어)
Column(
    modifier = Modifier
        .fillMaxSize()
        .padding(16.dp)
        .graphicsLayer { alpha = frontAlpha },
    horizontalAlignment = Alignment.CenterHorizontally,
    verticalArrangement = Arrangement.Center
) {
    if (!isFlipped) {
        Text(
            text = card.word,
            fontSize = 28.sp,
            fontWeight = FontWeight.Bold,
            textAlign = TextAlign.Center
        )
        Spacer(modifier = Modifier.height(12.dp))
        Text(
            text = "탭하여 뜻 보기",
            fontSize = 12.sp,
            color = Color.White.copy(alpha = 0.7f)
        )
    }
}

// 뒷면 (의미)
Column(
    modifier = Modifier
        .fillMaxSize()
        .padding(16.dp)
        .graphicsLayer { 
            alpha = backAlpha
            rotationY = 180f  
        },
    horizontalAlignment = Alignment.CenterHorizontally,
    verticalArrangement = Arrangement.Center
) {
    if (isFlipped) {
        Text(
            text = card.meaning,
            fontSize = 28.sp,
            fontWeight = FontWeight.Bold,
            textAlign = TextAlign.Center
        )
        Spacer(modifier = Modifier.height(12.dp))
        Text(
            text = "탭하여 단어 보기",
            fontSize = 12.sp,
            color = Color.White.copy(alpha = 0.7f)
        )
    }
}
  1. alpha = frontAlphaalpha = backAlpha로 서서히 나타나고 사라지는 효과를 준다
  2. if (!isFlipped)if (isFlipped) 조건으로 현재 상태에 맞는 내용만 보여준다
  3. 가장 중요한 부분: 뒷면에는 rotationY = 180f를 추가로 적용했다. 왜 그럴까??????
    • 카드 전체가 180도 회전하면 뒷면의 텍스트도 좌우가 반전된다(마치 거울에 비친 것처럼)
    • 이를 방지하기 위해 뒷면 내용물을 한 번 더 180도 회전시켜서 정상적으로 보이게 해준다.
    • 이것은 180도 + 180도 = 360도 = 0도와 같은 원리!!

기타 애니메이션 팁

  1. 애니메이션 시간 조절하기

    • 너무 빠르면 사용자가 무슨 일이 일어났는지 알아차리기 어렵다.
    • 너무 느리면 사용자가 지루해할 수 있다.
    • 0.3초~0.5초(300~500밀리초) 사이가 적당하다.
  2. if 조건문으로 성능 최적화하기

    • if (!isFlipped)if (isFlipped) 조건문은 애니메이션을 단순하지만 효과적으로 처리할 수 있게 해준다.
    • 안 보이는 면의 내용은 그리지 않아서 배터리와 처리 속도를 아낄 수 있다.

애니메이션 정리

  1. remember { mutableStateOf(false) } - 상태 값을 저장하고 변경을 추적

  2. animateFloatAsState() - 숫자 값을 부드럽게 애니메이션화하는 함수

  3. tween(durationMillis = 400) - 지정된 시간(밀리초) 동안 애니메이션을 실행하는 설정

  4. FastOutSlowInEasing - 처음에 빠르게 시작하고 끝에 천천히 마무리되는 애니메이션 속도 패턴

  5. graphicsLayer { rotationY = rotation } - Y축(세로축)을 기준으로 회전을 적용

  6. graphicsLayer { alpha = value } - 투명도 값을 적용하여 요소를 페이드 인/아웃

  7. cameraDistance = 12f * density - 3D 회전 효과의 깊이감을 조절

  8. clickable { isFlipped = !isFlipped } - 탭 이벤트로 애니메이션 상태를 전환

  9. graphicsLayer { rotationY = 180f } - 뒷면 콘텐츠의 좌우 반전을 방지하기 위한 회전 설정

  10. 조건부 렌더링(if (isFlipped)) - 현재 상태에 따라 콘텐츠를 선택적으로 표시하여 성능을 최적화

코드

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MaterialTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = Color.Black // 검은 배경으로 변경
                ) {
                    FlashCardScreen()
                }
            }
        }
    }
}

data class FlashCard(
    val id: Int,
    val word: String,
    val meaning: String
)

@Composable
fun FlashCardScreen() {
    val flashCards = listOf(
        FlashCard(1, "CPU", "중앙 처리 장치"),
        FlashCard(2, "RAM", "임의 접근 메모리"),
        FlashCard(3, "SSD", "솔리드 스테이트 드라이브"),
        FlashCard(4, "GPU", "그래픽 처리 장치"),
        FlashCard(5, "Motherboard", "메인보드"),
        FlashCard(6, "Power Supply", "전원 공급 장치"),
        FlashCard(7, "Cooling Fan", "냉각 팬"),
        FlashCard(8, "Hard Drive", "하드 드라이브")
    )

    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(
                brush = androidx.compose.ui.graphics.Brush.verticalGradient(
                    colors = listOf(Color(0xFF000000), Color(0xFF101035))
                )
            )
    ) {
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(16.dp),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text(
                text = "FLASH CARDS",
                fontSize = 32.sp,
                fontWeight = FontWeight.ExtraBold,
                letterSpacing = 2.sp,
                color = Color(0xFF00FFFF),
                modifier = Modifier
                    .padding(vertical = 24.dp)
                    .graphicsLayer {
                        shadowElevation = 10f
                    },
                style = MaterialTheme.typography.headlineLarge.copy(
                    shadow = Shadow(
                        color = Color(0xFF00FFFF),
                        blurRadius = 10f
                    )
                )
            )

            LazyColumn(
                contentPadding = PaddingValues(vertical = 8.dp),
                verticalArrangement = Arrangement.spacedBy(20.dp)
            ) {
                items(flashCards) { card ->
                    FlippableCard(card)
                }
            }
        }
    }
}

@Composable
fun FlippableCard(card: FlashCard) {
    var isFlipped by remember { mutableStateOf(false) }

    // 애니메이션 회전 값
    val rotation by animateFloatAsState(
        targetValue = if (isFlipped) 180f else 0f,
        animationSpec = tween(
            durationMillis = 400,
            easing = FastOutSlowInEasing
        ),
        label = "rotation"
    )

    // 앞면/뒷면 가시성
    val frontAlpha by animateFloatAsState(
        targetValue = if (isFlipped) 0f else 1f,
        animationSpec = tween(300),
        label = "frontAlpha"
    )

    val backAlpha by animateFloatAsState(
        targetValue = if (isFlipped) 1f else 0f,
        animationSpec = tween(300),
        label = "backAlpha"
    )

    // 네온 효과를 위한 글로우 애니메이션
    val glowAlpha by animateFloatAsState(
        targetValue = if (isFlipped) 0.8f else 0.6f,
        animationSpec = tween(500),
        label = "glowAlpha"
    )

    val borderColor = if (isFlipped) Color(0xFF00FFFF) else Color(0xFFFF00FF)

    Box(
        modifier = Modifier
            .fillMaxWidth()
            .height(150.dp)
            .padding(horizontal = 8.dp)
    ) {
        Card(
            modifier = Modifier
                .fillMaxSize()
                .graphicsLayer {
                    alpha = glowAlpha
                    shadowElevation = 20f
                    shape = RoundedCornerShape(20.dp)
                },
            shape = RoundedCornerShape(20.dp),
            colors = CardDefaults.cardColors(
                containerColor = borderColor.copy(alpha = 0.2f)
            ),
            elevation = CardDefaults.cardElevation(
                defaultElevation = 0.dp
            )
        ) {}

        Card(
            modifier = Modifier
                .fillMaxSize()
                .padding(4.dp)
                .graphicsLayer {
                    rotationY = rotation
                    cameraDistance = 12f * density
                }
                .clickable { isFlipped = !isFlipped },
            shape = RoundedCornerShape(18.dp),
            elevation = CardDefaults.cardElevation(
                defaultElevation = 8.dp
            ),
            colors = CardDefaults.cardColors(
                containerColor = Color(0xFF1A1A2E)
            ),
            border = androidx.compose.foundation.BorderStroke(
                width = 2.dp,
                brush = androidx.compose.ui.graphics.Brush.linearGradient(
                    colors = listOf(
                        borderColor,
                        borderColor.copy(alpha = 0.5f),
                        borderColor,
                        borderColor.copy(alpha = 0.5f)
                    )
                )
            )
        ) {
            Box(
                contentAlignment = Alignment.Center,
                modifier = Modifier.fillMaxSize()
            ) {
                // 앞면 (단어)
                Column(
                    modifier = Modifier
                        .fillMaxSize()
                        .padding(16.dp)
                        .graphicsLayer { alpha = frontAlpha },
                    horizontalAlignment = Alignment.CenterHorizontally,
                    verticalArrangement = Arrangement.Center
                ) {
                    if (!isFlipped) {
                        Text(
                            text = card.word,
                            fontSize = 28.sp,
                            fontWeight = FontWeight.Bold,
                            textAlign = TextAlign.Center,
                            color = Color(0xFFFF00FF), 
                            style = MaterialTheme.typography.headlineMedium.copy(
                                shadow = Shadow(
                                    color = Color(0xFFFF00FF),
                                    blurRadius = 15f
                                )
                            ),
                            letterSpacing = 1.sp
                        )
                        Spacer(modifier = Modifier.height(12.dp))
                        Text(
                            text = "TAP TO FLIP",
                            fontSize = 12.sp,
                            color = Color.White.copy(alpha = 0.7f),
                            letterSpacing = 2.sp
                        )
                    }
                }

                // 뒷면 (의미)
                Column(
                    modifier = Modifier
                        .fillMaxSize()
                        .padding(16.dp)
                        .graphicsLayer {
                            alpha = backAlpha
                            rotationY = 180f  // 뒷면은 텍스트가 뒤집혀 보이지 않도록 다시 회전
                        },
                    horizontalAlignment = Alignment.CenterHorizontally,
                    verticalArrangement = Arrangement.Center
                ) {
                    if (isFlipped) {
                        Text(
                            text = card.meaning,
                            fontSize = 28.sp,
                            fontWeight = FontWeight.Bold,
                            textAlign = TextAlign.Center,
                            color = Color(0xFF00FFFF), // 시안 네온 색상
                            style = MaterialTheme.typography.headlineMedium.copy(
                                shadow = Shadow(
                                    color = Color(0xFF00FFFF),
                                    blurRadius = 15f
                                )
                            ),
                            letterSpacing = 1.sp
                        )
                        Spacer(modifier = Modifier.height(12.dp))
                        Text(
                            text = "TAP TO FLIP",
                            fontSize = 12.sp,
                            color = Color.White.copy(alpha = 0.7f),
                            letterSpacing = 2.sp
                        )
                    }
                }
            }
        }
    }
}
profile
화려한 외면이 아닌 단단한 내면

0개의 댓글

관련 채용 정보