실제 카드를 손으로 뒤집는 걸 상상해보자!
카드는 어떻게 움직일까? 바로 세로축(Y축)을 중심으로 회전한다.
이 원리를 그대로 앱에 적용시키면 된당
카드가 뒤집히는 동안 일어나는 일:
1. 카드가 Y축을 중심으로 회전한다. (0도에서 180도까지)
2. 앞면이 서서히 사라지고, 뒷면이 서서히 나타난다.
3. 이 모든 과정이 부드럽게 진행된다.
먼저 카드가 현재 앞면인지 뒷면인지 기억해야 한다.
// 카드가 뒤집혔는지 상태 추적
var isFlipped by remember { mutableStateOf(false) }
isFlipped
: 카드가 뒤집혔는지 알려주는 변수false
면 앞면, true
면 뒷면을 의미remember
: 화면이 다시 그려져도 이 값을 기억하라는 명령mutableStateOf
: 이 값이 변할 수 있고, 값이 변하면 화면을 다시 그려야 한다는 의미이제 카드를 부드럽게 회전시키는 애니메이션을 만들어보자.
// 애니메이션 회전 값
val rotation by animateFloatAsState(
targetValue = if (isFlipped) 180f else 0f,
animationSpec = tween(
durationMillis = 400,
easing = FastOutSlowInEasing
),
label = "rotation"
)
animateFloatAsState
: 숫자값(float)을 부드럽게 변화시켜주는 애니메이션 도구targetValue
: 최종 목표값. 앞면이면 0도, 뒷면이면 180도임.tween
: 애니메이션의 속도와 움직임을 조절하는 설정durationMillis
: 애니메이션 지속 시간 (0.4초)FastOutSlowInEasing
: 애니메이션의 속도 변화 패턴이다.카드가 회전하는 동안 앞면이 서서히 사라지고 뒷면이 서서히 나타나도록 하자.
// 앞면/뒷면 가시성
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이면 완전히 불투명(잘 보임)이다.isFlipped
가 true
) 투명해지고(0), 앞면이면 불투명해진다(1)tween(300)
: 이 투명도 변화가 0.3초 동안 일어난다이제 준비한 애니메이션 값들을 실제 카드에 적용하자..
Card(
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
.graphicsLayer {
rotationY = rotation
cameraDistance = 12f * density
}
.clickable { isFlipped = !isFlipped },
// ... 카드 스타일 설정
) {
// 카드 내용
}
graphicsLayer
: 특별한 그래픽 효과(회전, 크기 조절 등)를 적용할 수 있게 해주는 도구rotationY
: Y축(세로축)을 중심으로 회전시키는 속성cameraDistance
: 화면과 카드 사이의 가상 거리. 이 값이 클수록 3D 효과가 더 자연스러워 보인다.density
: 화면 밀도. 다양한 기기에서 일관된 모습을 보여주기 위해 곱해준다.clickable
: 카드를 탭할 수 있게 해주고, 탭했을 때 isFlipped
값을 반대로 바꿔준다.이제 카드의 앞면과 뒷면에 내용을 넣자.
// 앞면 (단어)
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)
)
}
}
alpha = frontAlpha
와 alpha = backAlpha
로 서서히 나타나고 사라지는 효과를 준다if (!isFlipped)
와 if (isFlipped)
조건으로 현재 상태에 맞는 내용만 보여준다rotationY = 180f
를 추가로 적용했다. 왜 그럴까??????애니메이션 시간 조절하기
if 조건문으로 성능 최적화하기
if (!isFlipped)
와 if (isFlipped)
조건문은 애니메이션을 단순하지만 효과적으로 처리할 수 있게 해준다. remember { mutableStateOf(false) }
- 상태 값을 저장하고 변경을 추적
animateFloatAsState()
- 숫자 값을 부드럽게 애니메이션화하는 함수
tween(durationMillis = 400)
- 지정된 시간(밀리초) 동안 애니메이션을 실행하는 설정
FastOutSlowInEasing
- 처음에 빠르게 시작하고 끝에 천천히 마무리되는 애니메이션 속도 패턴
graphicsLayer { rotationY = rotation }
- Y축(세로축)을 기준으로 회전을 적용
graphicsLayer { alpha = value }
- 투명도 값을 적용하여 요소를 페이드 인/아웃
cameraDistance = 12f * density
- 3D 회전 효과의 깊이감을 조절
clickable { isFlipped = !isFlipped }
- 탭 이벤트로 애니메이션 상태를 전환
graphicsLayer { rotationY = 180f }
- 뒷면 콘텐츠의 좌우 반전을 방지하기 위한 회전 설정
조건부 렌더링(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
)
}
}
}
}
}
}