Animation trong Jetpack Compose
Animation làm cho ứng dụng trở nên sống động và tăng trải nghiệm người dùng. Compose cung cấp nhiều APIs mạnh mẽ để tạo animations một cách dễ dàng.
1. animate*AsState - Animation đơn giản nhất
Cách sử dụng
Thay đổi giá trị state → Compose tự động animate:
@Composable
fun ExpandingBox() {
var expanded by remember { mutableStateOf(false) }
// Tự động animate khi expanded thay đổi
val size by animateDpAsState(
targetValue = if (expanded) 200.dp else 100.dp,
label = "size"
)
Box(
modifier = Modifier
.size(size)
.background(Color.Blue)
.clickable { expanded = !expanded }
)
}Các variants
// Animate Dp
val size by animateDpAsState(targetValue = if (big) 200.dp else 100.dp)
// Animate Color
val color by animateColorAsState(targetValue = if (selected) Color.Red else Color.Gray)
// Animate Float
val alpha by animateFloatAsState(targetValue = if (visible) 1f else 0f)
// Animate Int
val count by animateIntAsState(targetValue = targetCount)
// Animate Offset
val offset by animateOffsetAsState(targetValue = Offset(x, y))Custom AnimationSpec
val size by animateDpAsState(
targetValue = if (expanded) 200.dp else 100.dp,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
),
label = "size"
)
val color by animateColorAsState(
targetValue = targetColor,
animationSpec = tween(durationMillis = 500, easing = LinearEasing),
label = "color"
)2. AnimatedVisibility - Hiển thị/ẩn với animation
Cơ bản
@Composable
fun ToastMessage(visible: Boolean, message: String) {
AnimatedVisibility(visible = visible) {
Card(modifier = Modifier.padding(16.dp)) {
Text(message, modifier = Modifier.padding(16.dp))
}
}
}Custom Enter/Exit Transitions
@Composable
fun SlideInContent(visible: Boolean) {
AnimatedVisibility(
visible = visible,
enter = fadeIn(animationSpec = tween(300)) +
slideInVertically(initialOffsetY = { -it }),
exit = fadeOut(animationSpec = tween(300)) +
slideOutVertically(targetOffsetY = { -it })
) {
Card {
Text("Sliding Content")
}
}
}Các EnterTransition
| Transition | Mô tả |
|---|---|
fadeIn() | Fade từ transparent |
slideInHorizontally() | Slide từ trái/phải |
slideInVertically() | Slide từ trên/dưới |
scaleIn() | Scale từ nhỏ |
expandIn() | Expand từ một góc |
expandHorizontally() | Expand theo chiều ngang |
expandVertically() | Expand theo chiều dọc |
Animate children riêng biệt
@Composable
fun AnimateChildren(visible: Boolean) {
AnimatedVisibility(
visible = visible,
enter = fadeIn(),
exit = fadeOut()
) {
Column {
Text(
"Title",
modifier = Modifier.animateEnterExit(
enter = slideInVertically { -it },
exit = slideOutVertically { -it }
)
)
Text(
"Subtitle",
modifier = Modifier.animateEnterExit(
enter = slideInVertically { it },
exit = slideOutVertically { it }
)
)
}
}
}3. AnimatedContent - Chuyển đổi nội dung
Cơ bản
@Composable
fun CounterAnimation(count: Int) {
AnimatedContent(
targetState = count,
label = "counter"
) { targetCount ->
Text(
text = "$targetCount",
style = MaterialTheme.typography.displayLarge
)
}
}Custom Transition
@Composable
fun TabContent(selectedTab: Int) {
AnimatedContent(
targetState = selectedTab,
transitionSpec = {
if (targetState > initialState) {
// Slide left khi chuyển sang tab bên phải
slideInHorizontally { it } + fadeIn() togetherWith
slideOutHorizontally { -it } + fadeOut()
} else {
// Slide right khi chuyển sang tab bên trái
slideInHorizontally { -it } + fadeIn() togetherWith
slideOutHorizontally { it } + fadeOut()
}.using(SizeTransform(clip = false))
},
label = "tab"
) { tab ->
when (tab) {
0 -> HomeTab()
1 -> SearchTab()
2 -> ProfileTab()
}
}
}4. Crossfade - Fade chuyển đổi
@Composable
fun LoadingState(isLoading: Boolean) {
Crossfade(
targetState = isLoading,
animationSpec = tween(500),
label = "loading"
) { loading ->
if (loading) {
CircularProgressIndicator()
} else {
Text("Content loaded!")
}
}
}5. updateTransition - Animation phức tạp
Khi bạn cần animate nhiều properties cùng lúc:
enum class ButtonState { Idle, Pressed }
@Composable
fun AnimatedButton() {
var buttonState by remember { mutableStateOf(ButtonState.Idle) }
val transition = updateTransition(targetState = buttonState, label = "button")
val scale by transition.animateFloat(label = "scale") { state ->
when (state) {
ButtonState.Idle -> 1f
ButtonState.Pressed -> 0.95f
}
}
val backgroundColor by transition.animateColor(label = "background") { state ->
when (state) {
ButtonState.Idle -> Color.Blue
ButtonState.Pressed -> Color.DarkGray
}
}
val borderWidth by transition.animateDp(label = "border") { state ->
when (state) {
ButtonState.Idle -> 0.dp
ButtonState.Pressed -> 2.dp
}
}
Box(
modifier = Modifier
.scale(scale)
.background(backgroundColor, RoundedCornerShape(8.dp))
.border(borderWidth, Color.White, RoundedCornerShape(8.dp))
.padding(16.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
buttonState = ButtonState.Pressed
tryAwaitRelease()
buttonState = ButtonState.Idle
}
)
}
) {
Text("Press Me", color = Color.White)
}
}6. rememberInfiniteTransition - Animation vô hạn
@Composable
fun PulsingDot() {
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
val scale by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 1.5f,
animationSpec = infiniteRepeatable(
animation = tween(1000),
repeatMode = RepeatMode.Reverse
),
label = "scale"
)
val alpha by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 0.3f,
animationSpec = infiniteRepeatable(
animation = tween(1000),
repeatMode = RepeatMode.Reverse
),
label = "alpha"
)
Box(
modifier = Modifier
.size(50.dp)
.scale(scale)
.alpha(alpha)
.background(Color.Red, CircleShape)
)
}Loading Shimmer Effect
@Composable
fun ShimmerEffect() {
val transition = rememberInfiniteTransition(label = "shimmer")
val translateX by transition.animateFloat(
initialValue = -300f,
targetValue = 300f,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing)
),
label = "shimmer"
)
Box(
modifier = Modifier
.fillMaxWidth()
.height(100.dp)
.background(
brush = Brush.horizontalGradient(
colors = listOf(
Color.LightGray,
Color.White,
Color.LightGray
),
startX = translateX,
endX = translateX + 200f
)
)
)
}7. Animatable - Low-level Control
Khi cần kiểm soát animation hoàn toàn:
@Composable
fun DraggableCard() {
val offsetX = remember { Animatable(0f) }
Box(
modifier = Modifier
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragEnd = {
// Spring back to original position
launch {
offsetX.animateTo(
targetValue = 0f,
animationSpec = spring(stiffness = Spring.StiffnessLow)
)
}
}
) { _, dragAmount ->
launch {
offsetX.snapTo(offsetX.value + dragAmount)
}
}
}
.size(100.dp)
.background(Color.Blue, RoundedCornerShape(8.dp))
)
}Animate to/SnapTo/Stop
val animatable = remember { Animatable(0f) }
// Animate với animation spec
animatable.animateTo(100f, animationSpec = tween(500))
// Jump trực tiếp đến giá trị
animatable.snapTo(100f)
// Dừng animation
animatable.stop()
// Animate với velocity (như fling)
animatable.animateDecay(initialVelocity = 1000f, animationSpec = exponentialDecay())8. AnimationSpec Types
tween - Easing-based
tween<Float>(
durationMillis = 300,
delayMillis = 50,
easing = FastOutSlowInEasing
)spring - Physics-based
spring<Float>(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)keyframes - Multi-step
keyframes<Float> {
durationMillis = 1000
0f at 0 with LinearEasing
0.5f at 200 with FastOutLinearInEasing
1f at 1000
}repeatable / infiniteRepeatable
repeatable<Float>(
iterations = 3,
animation = tween(500),
repeatMode = RepeatMode.Reverse
)
infiniteRepeatable<Float>(
animation = tween(500),
repeatMode = RepeatMode.Restart
)9. Gesture-based Animation
@Composable
fun SwipeableCard() {
var offset by remember { mutableStateOf(0f) }
var dismissed by remember { mutableStateOf(false) }
val animatedOffset by animateFloatAsState(
targetValue = if (dismissed) 1000f else offset,
animationSpec = spring(stiffness = Spring.StiffnessLow),
finishedListener = {
if (dismissed) {
// Handle dismiss
}
},
label = "swipe"
)
Box(
modifier = Modifier
.offset { IntOffset(animatedOffset.roundToInt(), 0) }
.draggable(
orientation = Orientation.Horizontal,
state = rememberDraggableState { delta ->
offset += delta
},
onDragStopped = { velocity ->
if (abs(offset) > 200 || abs(velocity) > 1000) {
dismissed = true
} else {
offset = 0f
}
}
)
.size(200.dp)
.background(Color.Blue, RoundedCornerShape(16.dp))
)
}10. Navigation Animation
@Composable
fun AnimatedNavHost() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "home"
) {
composable(
route = "home",
enterTransition = { fadeIn(tween(300)) },
exitTransition = { fadeOut(tween(300)) }
) {
HomeScreen()
}
composable(
route = "detail",
enterTransition = {
slideInHorizontally(initialOffsetX = { it }) + fadeIn()
},
exitTransition = {
slideOutHorizontally(targetOffsetX = { -it }) + fadeOut()
},
popEnterTransition = {
slideInHorizontally(initialOffsetX = { -it }) + fadeIn()
},
popExitTransition = {
slideOutHorizontally(targetOffsetX = { it }) + fadeOut()
}
) {
DetailScreen()
}
}
}📝 Tóm tắt
| API | Mục đích |
|---|---|
animate*AsState | Animation đơn giản |
AnimatedVisibility | Hiện/ẩn với animation |
AnimatedContent | Chuyển đổi nội dung |
Crossfade | Fade transition |
updateTransition | Animation phức tạp nhiều properties |
rememberInfiniteTransition | Animation vô hạn |
Animatable | Low-level control |
Chọn API nào?
Cần animate gì?
├── Một giá trị đơn lẻ → animate*AsState
├── Visibility → AnimatedVisibility
├── Content switch → AnimatedContent hoặc Crossfade
├── Nhiều properties cùng lúc → updateTransition
├── Infinite loop → rememberInfiniteTransition
└── Full control → AnimatableLast updated on