Jetpack Compose Animations: 앱을 살아있게 만드는 4가지 기술
Source: Dev.to
Animation은 현대 Android 앱의 심장박동과 같습니다. 정적인 UI를 유동적이고 반응성이 뛰어난 경험으로 바꾸어 사용자에게 자연스럽고 즐거운 느낌을 제공합니다. Jetpack Compose는 Google의 최신 선언형 UI 프레임워크로, 몇 줄의 코드만으로 부드러운 애니메이션을 만들 수 있는 강력한 내장 API를 제공합니다.
이 가이드에서는 Compose에서 앱의 느낌을 평범함에서 뛰어남으로 끌어올릴 수 있는 네 가지 핵심 애니메이션 기법을 단계별로 살펴보겠습니다. 각 기법마다 바로 프로젝트에 적용할 수 있는 실용적인 코드 예제가 함께 제공됩니다.
1. animateFloatAsState: 부드러운 값 변화를 위한 기본
animateFloatAsState는 Compose에서 가장 많이 사용되는 애니메이션 API일 가능성이 높습니다. 현재 상태에서 목표값으로 float 값을 부드럽게 애니메이션하여 투명도 페이드, 스케일 변화, 회전 등에 적합합니다.
사용 사례: 탭 시 애니메이션 버튼 투명도
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.clickable
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@Composable
fun AnimatedOpacityButton() {
var isPressed by remember { mutableStateOf(false) }
// Animate opacity from 1.0 to 0.5 when pressed
val alpha by animateFloatAsState(
targetValue = if (isPressed) 0.5f else 1.0f,
label = "button_alpha"
)
Button(
onClick = { isPressed = !isPressed },
modifier = Modifier.alpha(alpha)
) {
Text("Tap me")
}
}
작동 원리
- 부드러운 전환 – 기본적으로 300 ms 동안 애니메이션이 실행되어 자연스러운 페이드 효과를 만듭니다.
- 상태 기반 – 상태 조건이 변경될 때마다 애니메이션이 자동으로 트리거됩니다.
- 성능 – 저수준 그래픽 API를 사용해 부드러운 60 fps 애니메이션을 제공합니다.
애니메이션 속도 커스터마이징
animationSpec을 사용해 애니메이션 지속 시간을 제어할 수 있습니다:
val alpha by animateFloatAsState(
targetValue = if (isPressed) 0.5f else 1.0f,
animationSpec = tween(durationMillis = 500), // Slower fade
label = "button_alpha"
)
2. AnimatedVisibility: Show/Hide with Polish
animateFloatAsState가 값 변화를 처리하는 반면, AnimatedVisibility는 컴포저블을 뷰 계층에서 들어오고 나가게 하면서 우아한 진입/퇴장 애니메이션을 제공합니다.
Use Case: Animated Error Message
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Composable
fun LoginForm() {
var showError by remember { mutableStateOf(false) }
var email by remember { mutableStateOf("") }
Column(modifier = Modifier.padding(16.dp)) {
TextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") }
)
// Error message with slide + fade animation
AnimatedVisibility(
visible = showError,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut()
) {
Text(
"Invalid email format",
color = Color.Red,
modifier = Modifier
.background(Color(0xFFFFEBEE))
.padding(12.dp)
)
}
Button(onClick = {
showError = email.isEmpty() || !email.contains("@")
}) {
Text("Sign In")
}
}
}
Animation Combinations
Compose에서는 여러 애니메이션을 조합할 수 있습니다:
enter = slideInVertically() + fadeIn() + expandVertically()
exit = slideOutVertically() + fadeOut() + shrinkVertically()
이렇게 하면 오류 메시지가 한 번에 슬라이드 인, 페이드 인, 그리고 확장되는 세련된 효과를 만들 수 있습니다.
3. animateContentSize: 부드러운 레이아웃 변경
컴포저블의 크기가 콘텐츠 업데이트로 인해 변할 때, animateContentSize는 즉시 점프하는 대신 레이아웃 변화를 애니메이션으로 처리합니다.
사용 사례: 설명 텍스트 확장
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@Composable
fun ExpandableText(title: String, fullText: String) {
var isExpanded by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.clickable { isExpanded = !isExpanded }
.animateContentSize(animationSpec = tween(durationMillis = 400))
.padding(16.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall
)
Text(
text = fullText,
maxLines = if (isExpanded) Int.MAX_VALUE else 3,
overflow = TextOverflow.Ellipsis
)
}
}
이 스니펫들을 자유롭게 복사해서 프로젝트에 적용하고, 파라미터를 실험해 보면서 앱의 디자인 언어에 맞게 조정해 보세요.
마법
maxLines가 3에서 MAX_VALUE로 변경될 때, 컬럼의 높이가 부드럽게 확장됩니다. 갑작스러운 점프 없이—우아한 성장만이 있습니다.
Source: …
4. updateTransition + Crossfade: 복합 상태 애니메이션
여러 속성이 동시에 변하는 보다 복잡한 애니메이션의 경우, updateTransition은 모든 애니메이션을 하나의 논리적 단위로 조정합니다.
사용 사례: 스피너 회전이 포함된 로딩 상태
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
enum class LoadingState {
Idle, Loading, Success, Error
}
@Composable
fun LoadingIndicator(state: LoadingState) {
val transition = updateTransition(targetState = state, label = "loading_transition")
val rotation by transition.animateFloat(label = "rotation") {
if (it == LoadingState.Loading) 360f else 0f
}
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Crossfade(targetState = state, label = "content_crossfade") { loadingState ->
when (loadingState) {
LoadingState.Idle -> Text("Ready to load")
LoadingState.Loading -> CircularProgressIndicator(
modifier = Modifier
.size(40.dp)
.rotate(rotation)
)
LoadingState.Success -> Text("Loaded!", color = Color.Green)
LoadingState.Error -> Text("Error occurred", color = Color.Red)
}
}
}
}
updateTransition이 강력한 이유
- 조정(Orchestration) – 하나의 상태 변화에 묶인 모든 애니메이션이 동기화되어 실행됩니다.
- 일관성(Consistency) – 애니메이션 간 충돌을 방지합니다.
- 성능(Performance) – 애니메이션 값이 변할 때만 재구성하고, 매 프레임마다 재구성하지 않습니다.
보너스: Crossfade로 우아한 콘텐츠 전환
Crossfade는 이전 컴포저블을 서서히 사라지게 하고 새로운 컴포저블을 서서히 나타나게 하여 두 컴포저블 사이의 전환을 애니메이션합니다.
@Composable
fun TabContent(selectedTab: Int) {
Crossfade(targetState = selectedTab, label = "tab_switch") { tab ->
when (tab) {
0 -> HomeScreen()
1 -> ProfileScreen()
else -> SettingsScreen()
}
}
}
복잡한 진입/퇴장 로직이 필요하지 않으며, Crossfade가 시각적 우아함을 자동으로 처리합니다.
성능 팁
label파라미터 사용 – Android Studio의 Animation Inspector에서 디버깅에 도움이 됩니다.- 단순 값에는
animateFloatAsState선호 – 최적화되고 가볍습니다. - 초기 컴포지션 중 애니메이션 피하기 – 애니메이션이 시작되기 전에 상태가 안정되어야 합니다.
- 실제 디바이스에서 테스트 – 에뮬레이터는 애니메이션 성능을 정확히 나타내지 못합니다.
모두 합쳐 보기
각 기법은 특정 애니메이션 문제를 해결하며, 네 가지를 모두 마스터하면 Compose 앱이 진정으로 즐거운 느낌을 줄 것입니다.
| 기법 | 이상적인 사용 |
|---|---|
animateFloatAsState | 빠른 상태 피드백 (버튼 클릭, 토글) |
AnimatedVisibility | 화면 및 다이얼로그 진입/퇴장 |
animateContentSize | 유기적인 콘텐츠 성장 (리스트 확장, 텍스트) |
updateTransition | 복잡하고 동기화된 상태 변화 |
다음 단계
모든 8개의 템플릿은 애니메이션을 위한 깔끔한 Compose UI를 사용합니다:
다음 기능에서 animateFloatAsState를 구현하는 것으로 시작하고, 앱의 복잡성이 증가함에 따라 다른 기술들을 차례로 탐색하세요.
즐거운 애니메이션!