Bỏ qua để đến nội dung

Animation

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

Phần tiêu đề “1. animate*AsState - Animation đơn giản nhất”

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 }
)
}
// 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))
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

Phần tiêu đề “2. AnimatedVisibility - Hiển thị/ẩn với animation”
@Composable
fun ToastMessage(visible: Boolean, message: String) {
AnimatedVisibility(visible = visible) {
Card(modifier = Modifier.padding(16.dp)) {
Text(message, modifier = Modifier.padding(16.dp))
}
}
}
@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")
}
}
}
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
@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 }
)
)
}
}
}

@Composable
fun CounterAnimation(count: Int) {
AnimatedContent(
targetState = count,
label = "counter"
) { targetCount ->
Text(
text = "$targetCount",
style = MaterialTheme.typography.displayLarge
)
}
}
@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()
}
}
}

@Composable
fun LoadingState(isLoading: Boolean) {
Crossfade(
targetState = isLoading,
animationSpec = tween(500),
label = "loading"
) { loading ->
if (loading) {
CircularProgressIndicator()
} else {
Text("Content loaded!")
}
}
}

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

Phần tiêu đề “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)
)
}
@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
)
)
)
}

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))
)
}
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())

tween<Float>(
durationMillis = 300,
delayMillis = 50,
easing = FastOutSlowInEasing
)
spring<Float>(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
keyframes<Float> {
durationMillis = 1000
0f at 0 with LinearEasing
0.5f at 200 with FastOutLinearInEasing
1f at 1000
}
repeatable<Float>(
iterations = 3,
animation = tween(500),
repeatMode = RepeatMode.Reverse
)
infiniteRepeatable<Float>(
animation = tween(500),
repeatMode = RepeatMode.Restart
)

@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))
)
}

@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()
}
}
}

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