이전 글에서는 Jetpack Compose 애니메이션의 기초를 배웠습니다. 값의 부드러운 변화(animateFloatAsState
, animateColorAsState
), 레이아웃 애니메이션(animateContentSize
), 그리고 UI 전환 효과(AnimatedVisibility
, AnimatedContent
)를 통해 기본적인 애니메이션을 구현해봤죠.
하지만 실제 앱을 개발할 때는 더 복잡한 상황들을 마주하게 됩니다. 리스트에서 아이템을 추가하고 삭제할 때 다른 아이템들이 자연스럽게 재배치되어야 하고, 여러 애니메이션이 동시에 실행될 때 서로 간섭하지 않아야 하며, 성능도 고려해야 합니다.
이번 2부에서는 앱 개발에서 만날 수 있는 고급 애니메이션 기법들을 다뤄보겠습니다. 특히 리스트 애니메이션, 복합 애니메이션, 성능 최적화에 중점을 두고, 실제 프로젝트에서 활용해볼 수 있는 패턴들을 소개하겠습니다.
리스트 애니메이션은 많은 앱에서 볼 수 있지만 구현이 까다로운 부분입니다. 단순히 아이템을 추가/제거하는 것이 아니라, 다른 아이템들이 자연스럽게 위치를 이동하면서도 각 아이템의 고유성을 유지해야 합니다.
data class ListItem(
val id: Int,
val text: String
)
@Composable
fun AnimatedListExample() {
var items by remember {
mutableStateOf(
listOf(
ListItem(1, "아이템 1"),
ListItem(2, "아이템 2"),
ListItem(3, "아이템 3")
)
)
}
var nextItemId by remember { mutableIntStateOf(4) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
// 컨트롤 버튼들
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(
onClick = {
items = items + ListItem(nextItemId, "아이템 $nextItemId")
nextItemId++
},
modifier = Modifier.weight(1f)
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("추가")
}
Button(
onClick = {
if (items.isNotEmpty()) {
items = items.dropLast(1)
}
},
modifier = Modifier.weight(1f)
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("제거")
}
}
Spacer(modifier = Modifier.height(16.dp))
// 애니메이션 리스트
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
items = items,
key = { it.id }
) { item ->
AnimatedVisibility(
visible = true,
enter = slideInVertically(
initialOffsetY = { -it },
animationSpec = tween(500)
) + fadeIn(tween(500)) + expandVertically(tween(500)),
exit = slideOutVertically(
targetOffsetY = { it },
animationSpec = tween(500)
) + fadeOut(tween(500)) + shrinkVertically(tween(500)),
modifier = Modifier
.animateItem( // 위치 변경 애니메이션
fadeInSpec = tween(500),
fadeOutSpec = tween(500),
placementSpec = tween(500)
)
.fillMaxWidth()
) {
ListItemCard(
item = item,
onDelete = {
items = items.filter { it.id != item.id }
}
)
}
}
}
}
}
@Composable
fun ListItemCard(
item: ListItem,
onDelete: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = item.text,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
IconButton(onClick = onDelete) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "삭제",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
@Preview(showBackground = true)
@Composable
fun AnimatedListExamplePreview() {
AnimatedListExample()
}
key
파라미터: 각 아이템의 고유성을 보장하여 올바른 애니메이션 적용animateItem()
: 위치 변경을 자동으로 애니메이션 (이전 animateItemPlacement()
의 최신 대체)slideIn/Out + fade + expand/shrink
로 자연스러운 효과이제 1편에서 배운 기본 애니메이션들과 리스트 애니메이션을 조합해서 앱에서 사용할 수 있는 복합적인 UI를 만들어보겠습니다.
data class CardItem(
val id: Int,
val title: String,
val description: String,
val isFavorite: Boolean = false
)
@Composable
fun InteractiveCardCollection() {
var cards by remember {
mutableStateOf(
listOf(
CardItem(1, "첫 번째 카드", "이것은 첫 번째 카드의 상세 설명입니다. 카드를 클릭하면 이 내용을 볼 수 있어요."),
CardItem(2, "두 번째 카드", "두 번째 카드의 내용입니다. 즐겨찾기 기능도 사용해보세요!"),
CardItem(3, "세 번째 카드", "마지막 카드입니다. 삭제 버튼을 눌러 애니메이션을 확인해보세요.")
)
)
}
var nextCardId by remember { mutableIntStateOf(4) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
// 카드 추가 버튼
Button(
onClick = {
cards = cards + CardItem(
id = nextCardId,
title = "새로운 카드 $nextCardId",
description = "이것은 새로 추가된 카드입니다."
)
nextCardId++
},
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("새 카드 추가")
}
Spacer(modifier = Modifier.height(16.dp))
LazyColumn(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(
items = cards,
key = { it.id }
) { card ->
InteractiveCard(
card = card,
onFavoriteToggle = { cardId ->
cards = cards.map {
if (it.id == cardId) it.copy(isFavorite = !it.isFavorite)
else it
}
},
onDelete = { cardId ->
cards = cards.filter { it.id != cardId }
},
modifier = Modifier.animateItem(
fadeInSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
),
fadeOutSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
),
placementSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
)
)
)
}
}
}
}
@Composable
fun InteractiveCard(
card: CardItem,
onFavoriteToggle: (Int) -> Unit,
onDelete: (Int) -> Unit,
modifier: Modifier = Modifier
) {
var isExpanded by remember { mutableStateOf(false) }
val cardElevation by animateDpAsState(
targetValue = if (isExpanded) 12.dp else 4.dp,
animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMedium
),
label = "cardElevation"
)
val favoriteColor by animateColorAsState(
targetValue = if (card.isFavorite) Color.Red else Color.Gray,
animationSpec = tween(300),
label = "favoriteColor"
)
Card(
modifier = modifier
.fillMaxWidth()
.animateContentSize(
animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMedium
)
)
.clickable { isExpanded = !isExpanded },
elevation = CardDefaults.cardElevation(defaultElevation = cardElevation),
colors = CardDefaults.cardColors(
containerColor = if (isExpanded)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.surface
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
// 헤더
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = card.title,
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f)
)
Row {
// 즐겨찾기 버튼
IconButton(onClick = { onFavoriteToggle(card.id) }) {
Icon(
imageVector = if (card.isFavorite)
Icons.Filled.Favorite
else
Icons.Default.FavoriteBorder,
contentDescription = "즐겨찾기",
tint = favoriteColor
)
}
// 펼치기/접기 버튼
IconButton(onClick = { isExpanded = !isExpanded }) {
Icon(
imageVector = if (isExpanded)
Icons.Default.KeyboardArrowUp
else
Icons.Default.KeyboardArrowDown,
contentDescription = "펼치기/접기"
)
}
}
}
// 펼쳐지는 내용
AnimatedVisibility(
visible = isExpanded,
enter = slideInVertically(
initialOffsetY = { -it / 3 },
animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMedium
)
) + fadeIn(
spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMedium
)
) + expandVertically(
animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMedium
)
),
exit = slideOutVertically(
targetOffsetY = { -it / 3 },
animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMedium
)
) + fadeOut(
spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMedium
)
) + shrinkVertically(
animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMedium
)
)
) {
Column {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = card.description,
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(16.dp))
// 액션 버튼들
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(
onClick = { /* 편집 액션 */ },
modifier = Modifier.weight(1f)
) {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("편집")
}
Button(
onClick = { onDelete(card.id) },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
),
modifier = Modifier.weight(1f)
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("삭제")
}
}
}
}
}
}
}
@Preview(showBackground = true)
@Composable
fun AnimatedListExamplePreview() {
AnimatedListExample()
}
stiffness
와 dampingRatio
를 같게 설정앱에서 볼 수 있는 로딩 인디케이터를 만들어보겠습니다.
@Composable
fun PulsingLoadingIndicator() {
val infiniteTransition = rememberInfiniteTransition(label = "pulsingAnimation")
val scale by infiniteTransition.animateFloat(
initialValue = 0.7f,
targetValue = 1.3f,
animationSpec = infiniteRepeatable(
animation = tween(1200, easing = EaseInOutCubic),
repeatMode = RepeatMode.Reverse
),
label = "scale"
)
val alpha by infiniteTransition.animateFloat(
initialValue = 0.3f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(1200, easing = EaseInOutCubic),
repeatMode = RepeatMode.Reverse
),
label = "alpha"
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(100.dp)
.scale(scale)
.alpha(alpha)
) {
Box(
modifier = Modifier
.size(60.dp)
.background(
MaterialTheme.colorScheme.primary,
CircleShape
)
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
CircularProgressIndicator(
modifier = Modifier.size(30.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 3.dp
)
}
}
}
}
@Preview(showBackground = true)
@Composable
fun PulsingLoadingIndicatorPreview() {
MaterialTheme {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
PulsingLoadingIndicator()
}
}
}
모바일 앱에서 종 종 볼 수 있는 스와이프로 삭제하는 패턴입니다.
@Composable
fun SwipeToDeleteItem(
text: String,
onDelete: () -> Unit,
modifier: Modifier = Modifier
) {
var offsetX by remember(text) { mutableFloatStateOf(0f) }
val deleteThreshold = 80f
val animatedOffsetX by animateFloatAsState(
targetValue = offsetX,
animationSpec = tween(200),
label = "offsetX",
finishedListener = { finalValue ->
if (finalValue <= -300f) {
onDelete()
}
}
)
Box(
modifier = modifier
.fillMaxWidth()
.height(60.dp)
.clip(RoundedCornerShape(8.dp))
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.errorContainer),
contentAlignment = Alignment.CenterEnd
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "삭제",
tint = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(end = 16.dp)
)
}
Card(
modifier = Modifier
.fillMaxSize()
.offset { IntOffset(animatedOffsetX.roundToInt(), 0) }
.pointerInput(text) {
detectHorizontalDragGestures(
onDragEnd = {
if (offsetX < -deleteThreshold) {
offsetX = -300f
} else {
offsetX = 0f
}
}
) { _, dragAmount ->
offsetX = offsetX + dragAmount
if (offsetX > 0f) offsetX = 0f
}
}
) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
Icon(
imageVector = Icons.Default.Menu,
contentDescription = "드래그",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@Composable
fun SwipeToDeleteDemo() {
var items by remember {
mutableStateOf(
listOf(
"첫 번째 아이템",
"두 번째 아이템",
"세 번째 아이템",
"네 번째 아이템"
)
)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "스와이프하여 삭제",
style = MaterialTheme.typography.headlineSmall
)
Text(
text = "아이템을 왼쪽으로 드래그해보세요",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
items.forEach { item ->
SwipeToDeleteItem(
text = item,
onDelete = {
items = items - item
}
)
}
if (items.isEmpty()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().padding(32.dp)
) {
Text(
text = "모든 아이템이 삭제되었습니다!",
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
items = listOf(
"새로운 첫 번째 아이템",
"새로운 두 번째 아이템",
"새로운 세 번째 아이템",
"새로운 네 번째 아이템"
)
}
) {
Text("다시 생성")
}
}
}
}
}
@Preview(showBackground = true)
@Composable
fun SwipeToDeleteDemoPreview() {
MaterialTheme {
SwipeToDeleteDemo()
}
}
FAB를 누르면 여러 옵션이 부채꼴로 펼쳐지는 패턴입니다.
@Composable
fun ExpandableFAB() {
var isExpanded by remember { mutableStateOf(false) }
val fabRotation by animateFloatAsState(
targetValue = if (isExpanded) 45f else 0f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
),
label = "fabRotation"
)
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.BottomEnd
) {
Column(
horizontalAlignment = Alignment.End,
modifier = Modifier.padding(16.dp)
) {
AnimatedVisibility(
visible = isExpanded,
enter = slideInVertically(
initialOffsetY = { it },
animationSpec = spring(stiffness = Spring.StiffnessMedium)
) + fadeIn(spring()),
exit = slideOutVertically(
targetOffsetY = { it },
animationSpec = spring(stiffness = Spring.StiffnessMedium)
) + fadeOut(spring())
) {
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(bottom = 16.dp)
) {
listOf(
Icons.Default.Edit to "편집",
Icons.Default.Share to "공유",
Icons.Default.Favorite to "즐겨찾기"
).forEachIndexed { index, (icon, label) ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface
),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelMedium
)
}
FloatingActionButton(
onClick = {
isExpanded = false
},
modifier = Modifier.size(40.dp),
containerColor = MaterialTheme.colorScheme.secondary
) {
Icon(
imageVector = icon,
contentDescription = label,
modifier = Modifier.size(20.dp)
)
}
}
}
}
}
FloatingActionButton(
onClick = { isExpanded = !isExpanded },
modifier = Modifier.rotate(fabRotation)
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = "메뉴"
)
}
}
}
}
@Preview(showBackground = true)
@Composable
fun ExpandableFABPreview() {
ExpandableFAB()
}
애니메이션은 UI를 생동감 있게 만들지만, 무분별하게 사용하면 성능 저하나 사용자 경험 저해로 이어질 수 있습니다. 따라서 적절한 AnimationSpec 선택, 불필요한 애니메이션 최소화, 메모리 효율, 애니메이션 충돌 방지까지 고려해야 합니다.
애니메이션의 목적에 따라 다른 AnimationSpec
을 사용해야 합니다.
// 빠른 피드백이 필요한 경우 (버튼 터치 등)
animationSpec = tween(durationMillis = 100, easing = EaseOut)
// 자연스러운 움직임이 필요한 경우 (크기/위치 변화)
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
)
// 정확한 타이밍 연출이 필요한 경우
animationSpec = keyframes {
durationMillis = 1000
0.5f at 200 with EaseInOut // 200ms에 50% 도달
0.8f at 800 with EaseOut // 800ms에 80% 도달
}
모든 상태 변화에 애니메이션을 적용할 필요는 없습니다. 의미 있는 상태 변화에만 집중해야 성능과 사용자 경험을 모두 잡을 수 있습니다.
// ❌ 자주 변하는 값에 애니메이션 적용 (성능 저하)
val animatedValue by animateFloatAsState(frequentlyChangingValue)
// ✅ 의미 있는 상태 변화에만 애니메이션 적용
val animatedValue by animateFloatAsState(
targetValue = if (isImportantStateChanged) newValue else currentValue
)
// ✅ 필요할 때만 애니메이션 활성화
val animationSpec = if (shouldAnimate) {
spring(dampingRatio = Spring.DampingRatioMediumBouncy)
} else {
snap() // 즉시 변경
}
애니메이션 객체를 매번 새로 생성하면 불필요한 리소스 낭비가 발생합니다. remember
를 활용해 transition을 캐싱하고, 여러 애니메이션을 하나의 transition에서 관리하는 것이 좋습니다.
@Composable
fun EfficientAnimation() {
// ✅ remember로 transition 캐싱
val infiniteTransition = rememberInfiniteTransition(label = "efficientAnimation")
// ✅ 여러 값을 동시에 애니메이션
val progress by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(2000),
repeatMode = RepeatMode.Reverse
),
label = "progress"
)
val scale = 1f + (progress * 0.2f)
val alpha = 0.7f + (progress * 0.3f)
Box(
modifier = Modifier
.scale(scale)
.alpha(alpha)
) {
// 콘텐츠
}
}
여러 상태가 동시에 변할 때 애니메이션이 충돌하면 어색한 움직임이 발생합니다. 이럴 때는 여러 상태를 하나의 애니메이션 값으로 통합해 관리하는 것이 좋습니다.
@Composable
fun ConflictFreeAnimation() {
var isPressed by remember { mutableStateOf(false) }
var isHovered by remember { mutableStateOf(false) }
// 여러 상태를 하나의 targetScale 값으로 통합
val targetScale = when {
isPressed -> 0.95f
isHovered -> 1.05f
else -> 1f
}
val animatedScale by animateFloatAsState(
targetValue = targetScale,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessHigh
),
label = "buttonScale"
)
Button(
onClick = { /* 액션 */ },
modifier = Modifier
.scale(animatedScale)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed = true
tryAwaitRelease()
isPressed = false
}
)
}
.hoverable(interactionSource = remember { MutableInteractionSource() })
) {
Text("애니메이션 버튼")
}
}
이번 글에서는 Jetpack Compose 애니메이션의 실전 활용을 다뤘습니다.
이번 내용을 통해 애니메이션을 실제 앱 화면에 적용하는 다양한 방법을 살펴볼 수 있었습니다.
지금까지는 UI 자체에 집중해왔다면, 다음 글부터는 UI와 상태(State)의 연결을 살펴보겠습니다. Compose는 선언형 UI이기 때문에 상태 관리가 핵심이며, 이를 제대로 이해해야 앱을 안정적으로 만들 수 있습니다.
다음 글에서는 아래와 같은 내용을 다룰 예정입니다.
remember
, mutableStateOf
를 활용한 기본 상태 관리derivedStateOf
와 같은 파생 상태 계산ViewModel
과 StateFlow
를 이용한 화면 상태 구성정적인 UI를 넘어, 상태 기반으로 살아 움직이는 Compose 화면을 만들어보는 여정을 함께하겠습니다. 🚀