Animation trong Compose Multiplatform
Hướng dẫn tạo animations mượt mà trong Compose Multiplatform.
animateXAsState - Animation đơn giản
animateFloatAsState
@Composable
fun ExpandingBox() {
var expanded by remember { mutableStateOf(false) }
val size by animateFloatAsState(
targetValue = if (expanded) 200f else 100f,
animationSpec = tween(durationMillis = 300)
)
Box(
modifier = Modifier
.size(size.dp)
.background(Color.Blue)
.clickable { expanded = !expanded }
)
}animateColorAsState
@Composable
fun ColorChangingBox() {
var isRed by remember { mutableStateOf(true) }
val color by animateColorAsState(
targetValue = if (isRed) Color.Red else Color.Blue,
animationSpec = tween(500)
)
Box(
modifier = Modifier
.size(100.dp)
.background(color)
.clickable { isRed = !isRed }
)
}animateDpAsState
@Composable
fun SlidingBox() {
var isRight by remember { mutableStateOf(false) }
val offset by animateDpAsState(
targetValue = if (isRight) 200.dp else 0.dp,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
)
Box(
modifier = Modifier
.offset(x = offset)
.size(50.dp)
.background(Color.Green, CircleShape)
.clickable { isRight = !isRight }
)
}AnimationSpec Types
// Tween - Duration-based
tween(
durationMillis = 300,
delayMillis = 0,
easing = FastOutSlowInEasing // hoặc LinearEasing, LinearOutSlowInEasing
)
// Spring - Physics-based
spring(
dampingRatio = Spring.DampingRatioMediumBouncy, // 0.5f = bouncy
stiffness = Spring.StiffnessLow // 200f = slow
)
// Keyframes
keyframes {
durationMillis = 1000
0f at 0 with LinearEasing
0.5f at 500 with FastOutSlowInEasing
1f at 1000
}
// Repeatable
repeatable(
iterations = 3,
animation = tween(500),
repeatMode = RepeatMode.Reverse
)
// Infinite repeatable
infiniteRepeatable(
animation = tween(1000),
repeatMode = RepeatMode.Reverse
)AnimatedVisibility
@Composable
fun AnimatedVisibilityExample() {
var visible by remember { mutableStateOf(true) }
Column {
Button(onClick = { visible = !visible }) {
Text(if (visible) "Hide" else "Show")
}
AnimatedVisibility(
visible = visible,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text("This content can be shown/hidden", Modifier.padding(16.dp))
}
}
}
}Enter/Exit Animations
// Fade
fadeIn(animationSpec = tween(300))
fadeOut(animationSpec = tween(300))
// Slide
slideInHorizontally { fullWidth -> fullWidth } // from right
slideOutHorizontally { fullWidth -> -fullWidth } // to left
slideInVertically { fullHeight -> -fullHeight } // from top
slideOutVertically { fullHeight -> fullHeight } // to bottom
// Expand/Shrink
expandVertically()
shrinkVertically()
expandHorizontally()
shrinkHorizontally()
// Scale
scaleIn(initialScale = 0.5f)
scaleOut(targetScale = 0.5f)
// Combine
fadeIn() + slideInHorizontally()
fadeOut() + slideOutHorizontally()AnimatedContent
@Composable
fun AnimatedCounter() {
var count by remember { mutableStateOf(0) }
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = { count-- }) {
Icon(Icons.Default.Remove, "Decrease")
}
AnimatedContent(
targetState = count,
transitionSpec = {
if (targetState > initialState) {
// Going up
slideInVertically { -it } + fadeIn() togetherWith
slideOutVertically { it } + fadeOut()
} else {
// Going down
slideInVertically { it } + fadeIn() togetherWith
slideOutVertically { -it } + fadeOut()
}.using(SizeTransform(clip = false))
}
) { targetCount ->
Text(
text = "$targetCount",
style = MaterialTheme.typography.displayMedium
)
}
IconButton(onClick = { count++ }) {
Icon(Icons.Default.Add, "Increase")
}
}
}Crossfade
@Composable
fun CrossfadeExample() {
var currentScreen by remember { mutableStateOf("A") }
Column {
Row {
Button(onClick = { currentScreen = "A" }) { Text("Screen A") }
Button(onClick = { currentScreen = "B" }) { Text("Screen B") }
}
Crossfade(targetState = currentScreen, animationSpec = tween(500)) { screen ->
when (screen) {
"A" -> ScreenA()
"B" -> ScreenB()
}
}
}
}Infinite Animations
Pulsing animation
@Composable
fun PulsingDot() {
val infiniteTransition = rememberInfiniteTransition()
val scale by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 1.2f,
animationSpec = infiniteRepeatable(
animation = tween(500),
repeatMode = RepeatMode.Reverse
)
)
Box(
modifier = Modifier
.scale(scale)
.size(20.dp)
.background(Color.Red, CircleShape)
)
}Rotating animation
@Composable
fun RotatingIcon() {
val infiniteTransition = rememberInfiniteTransition()
val rotation by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
)
)
Icon(
Icons.Default.Refresh,
contentDescription = "Loading",
modifier = Modifier.rotate(rotation)
)
}Shimmer effect
@Composable
fun ShimmerEffect(
modifier: Modifier = Modifier
) {
val infiniteTransition = rememberInfiniteTransition()
val shimmerTranslate by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 1000f,
animationSpec = infiniteRepeatable(
animation = tween(1200, easing = LinearEasing),
repeatMode = RepeatMode.Restart
)
)
val shimmerColors = listOf(
Color.LightGray.copy(alpha = 0.6f),
Color.LightGray.copy(alpha = 0.2f),
Color.LightGray.copy(alpha = 0.6f)
)
val brush = Brush.linearGradient(
colors = shimmerColors,
start = Offset(shimmerTranslate - 200, shimmerTranslate - 200),
end = Offset(shimmerTranslate, shimmerTranslate)
)
Box(
modifier = modifier
.background(brush, RoundedCornerShape(8.dp))
)
}
// Skeleton loading
@Composable
fun SkeletonCard() {
Card(modifier = Modifier.fillMaxWidth()) {
Row(modifier = Modifier.padding(16.dp)) {
ShimmerEffect(
modifier = Modifier.size(48.dp)
)
Spacer(Modifier.width(16.dp))
Column {
ShimmerEffect(
modifier = Modifier
.height(16.dp)
.fillMaxWidth(0.7f)
)
Spacer(Modifier.height(8.dp))
ShimmerEffect(
modifier = Modifier
.height(12.dp)
.fillMaxWidth(0.5f)
)
}
}
}
}Gesture Animations
Swipe to dismiss
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SwipeToDismissItem(
item: String,
onDismiss: () -> Unit
) {
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = {
if (it == SwipeToDismissBoxValue.EndToStart) {
onDismiss()
true
} else {
false
}
}
)
SwipeToDismissBox(
state = dismissState,
backgroundContent = {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Red)
.padding(16.dp),
contentAlignment = Alignment.CenterEnd
) {
Icon(Icons.Default.Delete, "Delete", tint = Color.White)
}
}
) {
Card(modifier = Modifier.fillMaxWidth()) {
Text(item, modifier = Modifier.padding(16.dp))
}
}
}📝 Tóm tắt
| API | Mục đích |
|---|---|
animateXAsState | Animate single value |
AnimatedVisibility | Show/hide với animation |
AnimatedContent | Content change animation |
Crossfade | Fade between content |
rememberInfiniteTransition | Looping animations |
AnimationSpec
| Type | Use case |
|---|---|
tween | Time-based, easing |
spring | Physics-based, bouncy |
keyframes | Multiple steps |
repeatable | Limited loops |
infiniteRepeatable | Infinite loops |
Tiếp theo
Học về Bottom Sheet.
Last updated on