Jetpack Compose: 고급 애니메이션 단계별 가이드

Compose 고급 애니메이션 완벽 가이드: 전환, AnimatedVisibility, Animatable, 제스처, 부드러운 Android 인터페이스를 위한 성능.

Android 개발자를 위한 Jetpack Compose 고급 애니메이션

애니메이션은 기능적인 애플리케이션을 기억에 남는 사용자 경험으로 바꿉니다. Jetpack Compose는 부드러운 인터페이스 제작을 크게 단순화하는 강력한 선언형 애니메이션 API를 제공합니다. 이 가이드는 성능이 뛰어나고 유지보수가 쉬운 애니메이션을 구축하기 위한 고급 기법을 다룹니다.

사전 준비

이 튜토리얼은 Compose의 기본 개념(리컴포지션, 상태, Modifier)에 대한 이해를 전제로 합니다. 기초를 다지려면 먼저 Jetpack Compose 면접 질문 가이드를 참고하시기 바랍니다.

Compose 애니메이션 API의 기본

Compose는 애니메이션을 위한 여러 단계의 API를 제공합니다. 어떤 API를 선택할지는 원하는 제어 수준과 애니메이션의 복잡도에 따라 달라집니다.

API는 크게 세 가지 범주로 나뉩니다. 고수준 애니메이션(AnimatedVisibility, AnimatedContent), 상태 기반 애니메이션(animate*AsState), 저수준 애니메이션(Animatable, Transition)입니다.

AnimationLevels.ktkotlin
// Overview of the three animation API levels
@Composable
fun AnimationApiOverview() {
    // High level: simple predefined animations
    AnimatedVisibility(visible = isVisible) {
        Text("Animated content")
    }

    // Intermediate level: state-driven animation
    val alpha by animateFloatAsState(
        targetValue = if (isSelected) 1f else 0.5f,
        label = "alpha"
    )

    // Low level: full control over animation
    val animatable = remember { Animatable(0f) }
    LaunchedEffect(targetValue) {
        animatable.animateTo(targetValue)
    }
}

적절한 API 단계를 선택하는 것은 필요한 유연성을 유지하면서도 가독성 있는 코드를 작성하는 데 매우 중요합니다.

AnimatedVisibility: 우아한 등장과 퇴장 애니메이션

AnimatedVisibility는 요소의 등장과 사라짐을 애니메이션화하기에 이상적인 시작점입니다. 이 API는 콘텐츠의 컴포지션과 디컴포지션을 자동으로 처리합니다.

enterexit 매개변수는 애니메이션 동작을 정의하는 전환의 조합을 받습니다. 이러한 전환은 + 연산자로 결합할 수 있습니다.

AnimatedVisibilityExample.ktkotlin
@Composable
fun ExpandableCard(
    title: String,
    content: String,
    modifier: Modifier = Modifier
) {
    var isExpanded by remember { mutableStateOf(false) }

    Card(
        modifier = modifier.clickable { isExpanded = !isExpanded }
    ) {
        Column(modifier = Modifier.padding(16.dp)) {
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween
            ) {
                Text(text = title, style = MaterialTheme.typography.titleMedium)
                Icon(
                    imageVector = if (isExpanded) Icons.Default.ExpandLess
                                  else Icons.Default.ExpandMore,
                    contentDescription = null
                )
            }

            // Expansion animation with fade + slide
            AnimatedVisibility(
                visible = isExpanded,
                enter = fadeIn(animationSpec = tween(300)) +
                        expandVertically(animationSpec = tween(300)),
                exit = fadeOut(animationSpec = tween(200)) +
                       shrinkVertically(animationSpec = tween(200))
            ) {
                Text(
                    text = content,
                    modifier = Modifier.padding(top = 12.dp),
                    style = MaterialTheme.typography.bodyMedium
                )
            }
        }
    }
}

AnimatedVisibility 내부의 콘텐츠는 visible = true일 때만 컴포지션되므로, 확장 가능한 항목이 많은 리스트에서 성능이 최적화됩니다.

전환 결합하기

사용 가능한 전환에는 fadeIn/fadeOut, slideIn/slideOut, expandIn/shrinkOut, scaleIn/scaleOut 등이 있습니다. 자유롭게 결합하여 맞춤 효과를 만들 수 있습니다.

animate*AsState: 상태 주도 애니메이션

animate*AsState 계열의 함수는 원시 값의 변화를 자동으로 애니메이션화합니다. Compose에서 단순한 애니메이션을 구현하는 가장 관용적인 접근 방식입니다.

각 데이터 타입에는 전용 함수가 있습니다. animateColorAsState, animateFloatAsState, animateDpAsState, animateIntAsState 등입니다.

AnimateAsStateExample.ktkotlin
@Composable
fun InteractiveButton(
    isSelected: Boolean,
    onClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    // Animated background color
    val backgroundColor by animateColorAsState(
        targetValue = if (isSelected) MaterialTheme.colorScheme.primary
                      else MaterialTheme.colorScheme.surfaceVariant,
        animationSpec = tween(durationMillis = 250),
        label = "backgroundColor"
    )

    // Animated elevation
    val elevation by animateDpAsState(
        targetValue = if (isSelected) 8.dp else 2.dp,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness = Spring.StiffnessLow
        ),
        label = "elevation"
    )

    // Animated text size
    val textSize by animateFloatAsState(
        targetValue = if (isSelected) 18f else 14f,
        label = "textSize"
    )

    Surface(
        modifier = modifier.clickable(onClick = onClick),
        color = backgroundColor,
        shadowElevation = elevation,
        shape = RoundedCornerShape(12.dp)
    ) {
        Text(
            text = if (isSelected) "Selected" else "Select",
            modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp),
            fontSize = textSize.sp
        )
    }
}

animationSpec 매개변수는 애니메이션의 시간적 동작을 제어합니다. 가장 많이 사용되는 두 가지 spec은 tween(이징이 적용된 고정 지속 시간)과 spring(바운스가 있는 사실적인 물리)입니다.

Transition: 여러 애니메이션 조율하기

여러 속성을 협조적으로 애니메이션화해야 할 때 updateTransition은 중앙 집중식 제어를 제공합니다. 이 API는 모든 애니메이션이 동기화 상태를 유지하도록 보장합니다.

패턴은 enum 상태를 정의한 뒤, 그 상태에 의존하는 각 속성에 대해 애니메이션을 만드는 것입니다.

TransitionExample.ktkotlin
// Card state: defines visual behavior
enum class CardState { Collapsed, Expanded, Selected }

@Composable
fun AnimatedStateCard(
    cardState: CardState,
    modifier: Modifier = Modifier
) {
    // Central transition coordinating all animations
    val transition = updateTransition(
        targetState = cardState,
        label = "cardTransition"
    )

    // Card height based on state
    val cardHeight by transition.animateDp(
        transitionSpec = { spring(stiffness = Spring.StiffnessLow) },
        label = "height"
    ) { state ->
        when (state) {
            CardState.Collapsed -> 80.dp
            CardState.Expanded -> 200.dp
            CardState.Selected -> 160.dp
        }
    }

    // Border color based on state
    val borderColor by transition.animateColor(
        transitionSpec = { tween(300) },
        label = "borderColor"
    ) { state ->
        when (state) {
            CardState.Collapsed -> Color.Transparent
            CardState.Expanded -> MaterialTheme.colorScheme.outline
            CardState.Selected -> MaterialTheme.colorScheme.primary
        }
    }

    // Corner radius based on state
    val cornerRadius by transition.animateDp(
        label = "cornerRadius"
    ) { state ->
        when (state) {
            CardState.Collapsed -> 8.dp
            CardState.Expanded -> 16.dp
            CardState.Selected -> 24.dp
        }
    }

    Card(
        modifier = modifier
            .height(cardHeight)
            .border(2.dp, borderColor, RoundedCornerShape(cornerRadius)),
        shape = RoundedCornerShape(cornerRadius)
    ) {
        // Card content
    }
}

Android 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

Animatable: 애니메이션 완전 제어

Animatable은 완전한 프로그래밍적 제어를 제공하는 저수준 API입니다. 이 접근 방식은 중단 가능한 애니메이션, 제스처, 복잡한 시나리오에 필요합니다.

animate*AsState와 달리, Animatable은 진행 중인 애니메이션을 완료될 때까지 기다리지 않고 멈추거나 되돌리거나 수정할 수 있습니다.

AnimatableExample.ktkotlin
@Composable
fun SwipeableCard(
    onDismiss: () -> Unit,
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    // Horizontal offset controlled by Animatable
    val offsetX = remember { Animatable(0f) }
    val scope = rememberCoroutineScope()

    // Swipe threshold to trigger dismissal
    val dismissThreshold = 300f

    Box(
        modifier = modifier
            .offset { IntOffset(offsetX.value.roundToInt(), 0) }
            .pointerInput(Unit) {
                detectHorizontalDragGestures(
                    onDragEnd = {
                        scope.launch {
                            if (abs(offsetX.value) > dismissThreshold) {
                                // Animate out then callback
                                val target = if (offsetX.value > 0) 1000f else -1000f
                                offsetX.animateTo(
                                    targetValue = target,
                                    animationSpec = tween(200)
                                )
                                onDismiss()
                            } else {
                                // Return to initial position with spring
                                offsetX.animateTo(
                                    targetValue = 0f,
                                    animationSpec = spring(
                                        dampingRatio = Spring.DampingRatioMediumBouncy
                                    )
                                )
                            }
                        }
                    },
                    onHorizontalDrag = { _, dragAmount ->
                        scope.launch {
                            // snapTo for instant finger tracking
                            offsetX.snapTo(offsetX.value + dragAmount)
                        }
                    }
                )
            }
    ) {
        content()
    }
}

Animatable의 핵심 메서드는 animateTo()(목표값까지 애니메이션), snapTo()(즉시 변경), stop()(중단)입니다.

AnimatedContent: 콘텐츠 전환

AnimatedContent는 서로 다른 콘텐츠 사이의 전환을 애니메이션화합니다. 이 API는 표시되는 UI를 완전히 바꾸는 상태 변화에 적합합니다.

targetState 키는 전환이 언제 일어날지 결정합니다. transitionSpec은 나가는 콘텐츠와 들어오는 콘텐츠가 어떻게 상호작용할지 정의합니다.

AnimatedContentExample.ktkotlin
@Composable
fun CounterWithAnimation(
    count: Int,
    modifier: Modifier = Modifier
) {
    AnimatedContent(
        targetState = count,
        modifier = modifier,
        transitionSpec = {
            // Determine animation direction
            val direction = if (targetState > initialState) {
                // New number enters from top
                slideInVertically { height -> -height } + fadeIn() togetherWith
                slideOutVertically { height -> height } + fadeOut()
            } else {
                // New number enters from bottom
                slideInVertically { height -> height } + fadeIn() togetherWith
                slideOutVertically { height -> -height } + fadeOut()
            }
            direction.using(SizeTransform(clip = false))
        },
        label = "counter"
    ) { targetCount ->
        Text(
            text = "$targetCount",
            style = MaterialTheme.typography.displayLarge,
            fontWeight = FontWeight.Bold
        )
    }
}
AnimatedContent의 성능

AnimatedContent 내부의 콘텐츠는 targetState가 바뀔 때마다 리컴포지션됩니다. 복잡한 콘텐츠라면 비용이 큰 요소를 메모이제이션하거나 적절한 키 전략을 사용하시기 바랍니다.

rememberInfiniteTransition으로 무한 애니메이션

반복되는 애니메이션(로딩 인디케이터, 펄스 효과 등)에는 rememberInfiniteTransition이 별도의 사이클 관리 없이도 사용할 수 있는 전용 API를 제공합니다.

InfiniteTransitionExample.ktkotlin
@Composable
fun PulsingDot(
    color: Color = MaterialTheme.colorScheme.primary,
    modifier: Modifier = Modifier
) {
    val infiniteTransition = rememberInfiniteTransition(label = "pulse")

    // Looping scale animation
    val scale by infiniteTransition.animateFloat(
        initialValue = 0.8f,
        targetValue = 1.2f,
        animationSpec = infiniteRepeatable(
            animation = tween(600, easing = FastOutSlowInEasing),
            repeatMode = RepeatMode.Reverse
        ),
        label = "scale"
    )

    // Synchronized opacity animation
    val alpha by infiniteTransition.animateFloat(
        initialValue = 0.5f,
        targetValue = 1f,
        animationSpec = infiniteRepeatable(
            animation = tween(600, easing = FastOutSlowInEasing),
            repeatMode = RepeatMode.Reverse
        ),
        label = "alpha"
    )

    Box(
        modifier = modifier
            .size(24.dp)
            .scale(scale)
            .alpha(alpha)
            .background(color = color, shape = CircleShape)
    )
}

LazyColumn 리스트 애니메이션

리스트 항목 애니메이션은 특별한 주의가 필요합니다. animateItem() Modifier(이전 명칭은 animateItemPlacement)는 재정렬을 자동으로 애니메이션화합니다.

ListAnimationExample.ktkotlin
@Composable
fun AnimatedTaskList(
    tasks: List<Task>,
    onToggle: (Task) -> Unit,
    onDelete: (Task) -> Unit,
    modifier: Modifier = Modifier
) {
    LazyColumn(
        modifier = modifier,
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        items(
            items = tasks,
            key = { it.id }  // Stable key required for animateItem
        ) { task ->
            var isVisible by remember { mutableStateOf(true) }

            // Exit animation before deletion
            AnimatedVisibility(
                visible = isVisible,
                exit = shrinkVertically() + fadeOut()
            ) {
                TaskItem(
                    task = task,
                    onToggle = { onToggle(task) },
                    onDelete = {
                        isVisible = false
                        // Delay to let animation complete
                    },
                    modifier = Modifier.animateItem(
                        fadeInSpec = tween(300),
                        fadeOutSpec = tween(300),
                        placementSpec = spring(
                            dampingRatio = Spring.DampingRatioMediumBouncy,
                            stiffness = Spring.StiffnessLow
                        )
                    )
                )
            }

            // Trigger deletion after animation
            LaunchedEffect(isVisible) {
                if (!isVisible) {
                    delay(300)
                    onDelete(task)
                }
            }
        }
    }
}
리스트 애니메이션을 위한 안정적인 key

안정적인 key 매개변수가 없으면 animateItem은 리컴포지션 사이에서 항목을 추적할 수 없습니다. 리스트 인덱스 대신 고유한 ID를 사용하시기 바랍니다.

Canvas 애니메이션

맞춤 시각 효과에는 Canvas와 애니메이션 값을 결합하면 완전한 유연성을 얻을 수 있습니다.

CanvasAnimationExample.ktkotlin
@Composable
fun AnimatedProgressRing(
    progress: Float,  // 0f to 1f
    modifier: Modifier = Modifier
) {
    // Progress animation with spring for natural feel
    val animatedProgress by animateFloatAsState(
        targetValue = progress,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioLowBouncy,
            stiffness = Spring.StiffnessVeryLow
        ),
        label = "progress"
    )

    // Continuous rotation animation
    val infiniteTransition = rememberInfiniteTransition(label = "rotation")
    val rotation by infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = 360f,
        animationSpec = infiniteRepeatable(
            animation = tween(2000, easing = LinearEasing)
        ),
        label = "rotation"
    )

    val primaryColor = MaterialTheme.colorScheme.primary
    val trackColor = MaterialTheme.colorScheme.surfaceVariant

    Canvas(
        modifier = modifier
            .size(120.dp)
            .rotate(rotation)
    ) {
        val strokeWidth = 12.dp.toPx()
        val radius = (size.minDimension - strokeWidth) / 2

        // Background circle (track)
        drawCircle(
            color = trackColor,
            radius = radius,
            style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
        )

        // Animated progress arc
        drawArc(
            color = primaryColor,
            startAngle = -90f,
            sweepAngle = animatedProgress * 360f,
            useCenter = false,
            style = Stroke(width = strokeWidth, cap = StrokeCap.Round),
            topLeft = Offset(strokeWidth / 2, strokeWidth / 2),
            size = Size(radius * 2, radius * 2)
        )
    }
}

Android 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

애니메이션 성능 최적화

잘못 최적화된 애니메이션은 잰크(jank)를 유발하고 배터리를 소모시킵니다. 60 FPS를 유지하기 위한 모범 사례를 소개합니다.

첫 번째 규칙은 애니메이션 도중 할당을 피하는 것입니다. 리컴포지션을 유발하는 Modifier 대신 graphicsLayer를 사용해야 합니다.

PerformanceOptimization.ktkotlin
@Composable
fun OptimizedAnimatedCard(
    isExpanded: Boolean,
    modifier: Modifier = Modifier
) {
    val scale by animateFloatAsState(
        targetValue = if (isExpanded) 1.1f else 1f,
        label = "scale"
    )

    val alpha by animateFloatAsState(
        targetValue = if (isExpanded) 1f else 0.8f,
        label = "alpha"
    )

    Card(
        modifier = modifier
            // ✅ graphicsLayer: GPU modifications without recomposition
            .graphicsLayer {
                scaleX = scale
                scaleY = scale
                this.alpha = alpha
            }
        // ❌ Avoid: .scale(scale).alpha(alpha)
        // These modifiers trigger recompositions
    ) {
        Text("Card content")
    }
}

// Example with remembered lambda to avoid allocations
@Composable
fun OptimizedClickableItem(
    onClick: () -> Unit,
    content: @Composable () -> Unit
) {
    // ✅ Stable remembered lambda
    val interactionSource = remember { MutableInteractionSource() }

    Box(
        modifier = Modifier
            .clickable(
                interactionSource = interactionSource,
                indication = ripple(),
                onClick = onClick
            )
    ) {
        content()
    }
}

두 번째 핵심 포인트는 리스트 안의 애니메이션입니다. 동시 애니메이션 수를 제한하고 파생 계산에는 derivedStateOf를 사용해야 합니다.

ListPerformance.ktkotlin
@Composable
fun PerformantAnimatedList(
    items: List<Item>,
    modifier: Modifier = Modifier
) {
    // Calculate once whether the list is empty
    val isEmpty by remember {
        derivedStateOf { items.isEmpty() }
    }

    LazyColumn(modifier = modifier) {
        items(
            items = items,
            key = { it.id }
        ) { item ->
            // Lightweight animation only on initial appearance
            var hasAppeared by remember { mutableStateOf(false) }

            LaunchedEffect(Unit) {
                hasAppeared = true
            }

            val alpha by animateFloatAsState(
                targetValue = if (hasAppeared) 1f else 0f,
                animationSpec = tween(200),
                label = "itemAlpha"
            )

            ItemCard(
                item = item,
                modifier = Modifier.graphicsLayer { this.alpha = alpha }
            )
        }
    }
}

결론

Jetpack Compose의 애니메이션은 사용 편의성과 고급 제어 사이의 균형을 제공합니다. 기억해야 할 핵심 포인트는 다음과 같습니다.

  • ✅ 복잡도에 따라 적절한 API 단계를 선택하기 (AnimatedVisibility → animate*AsState → Animatable)
  • ✅ 관련된 여러 애니메이션을 조율할 때는 updateTransition 사용하기
  • ✅ 자연스러운 애니메이션에는 spring, 정확한 지속 시간에는 tween 선호하기
  • ✅ 리스트 애니메이션에는 항상 안정적인 key 매개변수 제공하기
  • ✅ 불필요한 리컴포지션을 피하기 위해 graphicsLayer로 최적화하기
  • ✅ 성능을 검증하기 위해 실제 기기에서 애니메이션 테스트하기

Compose 애니메이션을 능숙하게 다루는 것은 프로페셔널한 Android 애플리케이션을 차별화하는 요소입니다. 이러한 기법과 성능에 대한 주의가 결합되면 부드럽고 매력적인 사용자 경험을 만들 수 있습니다.

연습을 시작하세요!

면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.

태그

#jetpack compose
#android
#animations
#kotlin
#ui

공유

관련 기사