Skip to Content

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

TransitionMô 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

APIMục đích
animate*AsStateAnimation đơn giản
AnimatedVisibilityHiện/ẩn với animation
AnimatedContentChuyển đổi nội dung
CrossfadeFade transition
updateTransitionAnimation phức tạp nhiều properties
rememberInfiniteTransitionAnimation vô hạn
AnimatableLow-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 → Animatable
Last updated on