1. Pengenalan Compose Animations
Jetpack Compose memiliki sistem animasi yang powerful dan deklaratif. Dibandingkan View system lama yang memerlukan ObjectAnimator, ValueAnimator, dan XML animation, Compose menyediakan API yang lebih intuitif dan terintegrasi langsung dengan recomposition.
Tingkat Animasi di Compose
| Tingkat | API | Kompleksitas | Kasus Penggunaan |
|---|---|---|---|
| High-level | AnimatedVisibility, AnimatedContent, Crossfade | 🟢 Mudah | Show/hide, page transition, content swap |
| Mid-level | animate*AsState, updateTransition | 🟡 Sedang | Color, size, offset changes, multi-state animation |
| Low-level | Animatable, AnimationState | 🔴 Lanjutan | Custom gesture animation, physics, sequential |
┌─────────────────────────────────────────────────────────────┐ │ COMPOSE ANIMATIONS │ │ │ │ ┌────────────────────────────────────────────────────────┐ │ │ │ HIGH-LEVEL (Declarative, mudah) │ │ │ │ │ │ │ │ • AnimatedVisibility — show/hide dengan animasi │ │ │ │ • AnimatedContent — swap konten dengan animasi │ │ │ │ • Crossfade — fade antar konten │ │ │ └──────────────────────┬─────────────────────────────────┘ │ │ │ │ │ ┌──────────────────────┴─────────────────────────────────┐ │ │ │ MID-LEVEL (Target-based) │ │ │ │ │ │ │ │ • animateColorAsState() — warna │ │ │ │ • animateDpAsState() — ukuran/offset │ │ │ │ • animateFloatAsState() — opacity, scale │ │ │ │ • updateTransition() — multi-state animation │ │ │ └──────────────────────┬─────────────────────────────────┘ │ │ │ │ │ ┌──────────────────────┴─────────────────────────────────┐ │ │ │ LOW-LEVEL (Imperative, fleksibel) │ │ │ │ │ │ │ │ • Animatable — kontrol penuh │ │ │ │ • animate() — raw animation function │ │ │ │ • AnimationState — observasi state │ │ │ └────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘
2. animate*AsState
animate*AsState adalah API tingkat menengah yang paling sering digunakan. Setiap kali nilai target berubah, animasi otomatis dijalankan dari nilai lama ke nilai baru.
2.1 animateColorAsState
@Composable
fun ColorAnimationExample() {
var isActive by remember { mutableStateOf(false) }
// Animasi warna dari current → target
val backgroundColor by animateColorAsState(
targetValue = if (isActive) Color(0xFF4CAF50) else Color(0xFFF44336),
animationSpec = tween(durationMillis = 500),
label = "backgroundColor", // Untuk Compose preview & inspection
)
val textColor by animateColorAsState(
targetValue = if (isActive) Color.White else Color.Black,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow,
),
label = "textColor",
)
Box(
modifier = Modifier
.size(200.dp)
.clip(RoundedCornerShape(16.dp))
.background(backgroundColor)
.clickable { isActive = !isActive },
contentAlignment = Alignment.Center,
) {
Text(
text = if (isActive) "AKTIF" else "NONAKTIF",
color = textColor,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
)
}
}
2.2 animateDpAsState
@Composable
fun SizeAnimationExample() {
var isExpanded by remember { mutableStateOf(false) }
// Animasi ukuran
val width by animateDpAsState(
targetValue = if (isExpanded) 300.dp else 100.dp,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium,
),
label = "width",
)
val height by animateDpAsState(
targetValue = if (isExpanded) 200.dp else 100.dp,
animationSpec = tween(400, easing = FastOutSlowInEasing),
label = "height",
)
val cornerRadius by animateDpAsState(
targetValue = if (isExpanded) 24.dp else 50.dp,
label = "cornerRadius",
)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.width(width)
.height(height)
.clip(RoundedCornerShape(cornerRadius))
.background(MaterialTheme.colorScheme.primary)
.clickable { isExpanded = !isExpanded },
contentAlignment = Alignment.Center,
) {
Text(
text = if (isExpanded) "Kecilkan" : "Perbesar",
color = Color.White,
)
}
}
}
2.3 animateFloatAsState
@Composable
fun FloatAnimationExample() {
var isVisible by remember { mutableStateOf(true) }
var isRotated by remember { mutableStateOf(false) }
// Opacity animation
val alpha by animateFloatAsState(
targetValue = if (isVisible) 1f else 0f,
animationSpec = tween(300),
label = "alpha",
)
// Scale animation
val scale by animateFloatAsState(
targetValue = if (isVisible) 1f else 0.5f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow,
),
label = "scale",
)
// Rotation animation
val rotation by animateFloatAsState(
targetValue = if (isRotated) 360f else 0f,
animationSpec = tween(800, easing = LinearEasing),
label = "rotation",
)
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
// Animated box
Box(
modifier = Modifier
.graphicsLayer {
this.alpha = alpha
this.scaleX = scale
this.scaleY = scale
this.rotationZ = rotation
}
.size(120.dp)
.background(Color(0xFF2196F3), RoundedCornerShape(16.dp)),
contentAlignment = Alignment.Center,
) {
Text("✨", fontSize = 40.sp)
}
Spacer(Modifier.height(32.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { isVisible = !isVisible }) {
Text(if (isVisible) "Hide" else "Show")
}
Button(onClick = { isRotated = !isRotated }) {
Text("Rotate")
}
}
}
}
Semua animate*AsState yang Tersedia
| Fungsi | Tipe | Untuk |
|---|---|---|
animateFloatAsState | Float | Opacity, scale, rotation, offset |
animateDpAsState | Dp | Size, padding, offset, corner radius |
animateColorAsState | Color | Background, text color, border color |
animateIntAsState | Int | Badge count, step indicator |
animateIntOffsetAsState | IntOffset | Position offset |
animateSizeAsState | Size | Width & height simultaneously |
animateRectAsState | Rect | Rectangle bounds |
animateOffsetAsState | Offset | 2D position |
3. Animation Specs
Animation Spec menentukan bagaimana animasi berjalan — durasi, easing, fisika, dll.
3.1 Jenis Animation Spec
import androidx.compose.animation.core.*
// ── 1. tween: Durasi tetap + easing ──
// Cocok untuk: fade, slide, transformasi sederhana
val tweenSpec = tween<Float>(
durationMillis = 300,
delayMillis = 100, // Delay sebelum mulai
easing = FastOutSlowInEasing, // Easing curve
)
// Easing yang tersedia:
// FastOutSlowInEasing — cepat di awal, lambat di akhir (default Material)
// LinearOutSlowInEasing — linear di awal, lambat di akhir
// FastOutLinearEasing — cepat di awal, linear di akhir
// LinearEasing — konstan
// CubicBezierEasing(a, b, c, d) — custom bezier
// AnticipateEasing — mundur dulu lalu maju
// OvershootEasing — lewat target lalu balik
// Custom cubic bezier (iOS-like spring curve)
val iOSEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f)
// ── 2. spring: Fisika spring ──
// Cocok untuk: gesture response, bouncy effect
val springSpec = spring<Float>(
dampingRatio = Spring.DampingRatioMediumBouncy, // 0.5
stiffness = Spring.StiffnessMedium, // 1500f
visibilityThreshold = 0.01f, // Threshold untuk dianggap "selesai"
)
// Preset damping ratios:
// DampingRatioHighBouncy = 0.2f — Sangat bouncy
// DampingRatioMediumBouncy = 0.5f — Bouncy
// DampingRatioLowBouncy = 0.75f — Sedikit bouncy
// NoBouncing = 1.0f — Tidak bounce
// Preset stiffness:
// StiffnessVeryLow = 50f
// StiffnessLow = 200f
// StiffnessMedium = 1500f
// StiffnessHigh = 10000f
// StiffnessVeryHigh = 20000f
// ── 3. keyframes: Animasi dengan keyframes ──
// Cocok untuk: animasi kompleks multi-tahap
val keyframeSpec = keyframes<Float>(
durationMillis = 600
) {
0f at 0 // Mulai di 0
0.8f at 200 // Lompat ke 0.8 di 200ms
1.2f at 400 // Overshoot ke 1.2 di 400ms
1f at 600 // Kembali ke 1.0 di 600ms
}
// ── 4. repeatable: Animasi berulang ──
val repeatableSpec = repeatable<Float>(
iterations = 5, // Ulang 5 kali
animation = tween(200),
repeatMode = RepeatMode.Reverse, // Bolak-balik
)
// RepeatMode.Restart — restart dari awal
// RepeatMode.Reverse — bolak-balik
// ── 5. infiniteRepeatable: Berulang selamanya ──
val infiniteSpec = infiniteRepeatable<Float>(
animation = tween(1000),
repeatMode = RepeatMode.Reverse,
)
// ── 6. snap: Langsung tanpa animasi ──
val snapSpec = snap<Float>(delayMillis = 0)
// ── 7. exponentialRepeatable: Berulang dengan exponential decay ──
val exponentialSpec = exponentialRepeatable<Float>(
animation = tween(500),
)
4. AnimatedVisibility
AnimatedVisibility secara otomatis menambahkan animasi enter dan exit saat visibility komponen berubah.
4.1 Dasar AnimatedVisibility
@Composable
fun AnimatedVisibilityExample() {
var isVisible by remember { mutableStateOf(true) }
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Button(onClick = { isVisible = !isVisible }) {
Text(if (isVisible) "Hide" else "Show")
}
Spacer(Modifier.height(16.dp))
// ── Basic: Fade + Expand/Shrink ──
AnimatedVisibility(visible = isVisible) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
),
) {
Text(
"Konten yang muncul dan hilang!",
modifier = Modifier.padding(16.dp),
)
}
}
}
}
4.2 Custom Enter & Exit Transitions
@Composable
fun CustomTransitionExample() {
var isVisible by remember { mutableStateOf(true) }
Column(modifier = Modifier.padding(16.dp)) {
Button(onClick = { isVisible = !isVisible }) {
Text("Toggle")
}
Spacer(Modifier.height(16.dp))
// ── Slide + Fade ──
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically(
initialOffsetY = { -it }, // Slide dari atas
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
),
) + fadeIn(
initialAlpha = 0.3f,
animationSpec = tween(300),
),
exit = slideOutVertically(
targetOffsetY = { -it }, // Slide ke atas
animationSpec = tween(300),
) + fadeOut(
animationSpec = tween(200),
),
) {
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = Color(0xFF4CAF50),
),
) {
Text("Slide + Fade!", Modifier.padding(16.dp), color = Color.White)
}
}
Spacer(Modifier.height(16.dp))
// ── Expand Horizontally ──
AnimatedVisibility(
visible = isVisible,
enter = expandHorizontally(
expandFrom = Alignment.Start,
animationSpec = spring(dampingRatio = Spring.DampingRatioLowBouncy),
),
exit = shrinkHorizontally(
shrinkTowards = Alignment.Start,
),
) {
Box(
Modifier
.fillMaxWidth()
.height(60.dp)
.background(Color(0xFF2196F3), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
Text("Expand Horizontal!", color = Color.White)
}
}
Spacer(Modifier.height(16.dp))
// ── Scale + Fade ──
AnimatedVisibility(
visible = isVisible,
enter = scaleIn(
initialScale = 0.5f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
),
) + fadeIn(),
exit = scaleOut(targetScale = 0.5f) + fadeOut(),
) {
Box(
Modifier
.size(120.dp)
.background(Color(0xFFFF9800), CircleShape),
contentAlignment = Alignment.Center,
) {
Text("🔍", fontSize = 40.sp)
}
}
}
}
4.3 Semua Enter/Exit Transition
| Enter Transition | Exit Transition | Deskripsi |
|---|---|---|
fadeIn() | fadeOut() | Opacity 0→1 / 1→0 |
slideInHorizontally() | slideOutHorizontally() | Slide horizontal |
slideInVertically() | slideOutVertically() | Slide vertikal |
expandIn() | shrinkOut() | Expand/shrink dari center |
expandHorizontally() | shrinkHorizontally() | Expand horizontal |
expandVertically() | shrinkVertically() | Expand vertikal |
scaleIn() | scaleOut() | Scale |
5. AnimatedContent
AnimatedContent menganimasikan perubahan target state — konten lama keluar dan konten baru masuk dengan animasi.
@Composable
fun AnimatedContentExample() {
var currentPage by remember { mutableIntStateOf(0) }
val pages = listOf("Beranda", "Profil", "Pengaturan", "Tentang")
Column(modifier = Modifier.padding(16.dp)) {
// Tab buttons
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
pages.forEachIndexed { index, title ->
FilterChip(
selected = currentPage == index,
onClick = { currentPage = index },
label = { Text(title) },
)
}
}
Spacer(Modifier.height(16.dp))
// Animated content
AnimatedContent(
targetState = currentPage,
transitionSpec = {
// Slide + fade animation
if (targetState > initialState) {
// Navigasi ke kanan
slideInHorizontally { it } + fadeIn() togetherWith
slideOutHorizontally { -it } + fadeOut()
} else {
// Navigasi ke kiri
slideInHorizontally { -it } + fadeIn() togetherWith
slideOutHorizontally { it } + fadeOut()
}.using(SizeTransform(clip = false))
},
label = "pageTransition",
) { page ->
// Content based on page
Card(
modifier = Modifier.fillMaxWidth().height(200.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
),
) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
"Halaman: ${pages[page]}",
style = MaterialTheme.typography.headlineMedium,
)
}
}
}
}
}
5.1 SizeTransform
// ── Tanpa SizeTransform (default) ──
// Konten baru langsung menggantikan — ukuran berubah mendadak
AnimatedContent(targetState = state) { target ->
// content
}
// ── Dengan SizeTransform ──
// Animasi perubahan ukuran dari konten lama ke konten baru
AnimatedContent(
targetState = state,
transitionSpec = {
fadeIn() togetherWith fadeOut() using SizeTransform(
clip = false, // Jangan clip konten yang overflow
)
},
) { target ->
// content
}
// ── SizeTransform dengan custom animation ──
AnimatedContent(
targetState = state,
transitionSpec = {
fadeIn(tween(300)) togetherWith fadeOut(tween(300)) using
SizeTransform(
clip = false,
sizeAnimationSpec = { initialSize, targetSize ->
// Animasi ukuran dengan spring
if (targetState > initialState) {
// Expand: width dulu, lalu height
keyframes {
IntSize(targetSize.width, initialSize.height) at 150
targetSize at 300
}
} else {
// Shrink: height dulu, lalu width
keyframes {
IntSize(initialSize.width, targetSize.height) at 150
targetSize at 300
}
}
},
)
},
) { target ->
// content
}
6. updateTransition
updateTransition mengelola animasi yang melibatkan banyak nilai yang berubah bersamaan saat state berubah. Berguna untuk UI yang memiliki beberapa state (misalnya: idle → loading → success → error).
// ── State definitions ──
enum class FabState { Collapsed, Expanded }
@Composable
fun TransitionExample() {
var currentState by remember { mutableStateOf(FabState.Collapsed) }
// Create transition
val transition = updateTransition(
targetState = currentState,
label = "fabTransition",
)
// Animasi berdasarkan state
val size by transition.animateDp(
transitionSpec = {
when {
FabState.Expanded isTransitioningTo FabState.Collapsed ->
tween(200)
else -> spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow,
)
}
},
label = "size",
) { state ->
when (state) {
FabState.Collapsed -> 56.dp
FabState.Expanded -> 200.dp
}
}
val cornerRadius by transition.animateDp(
transitionSpec = { spring(dampingRatio = Spring.DampingRatioMediumBouncy) },
label = "cornerRadius",
) { state ->
when (state) {
FabState.Collapsed -> 28.dp
FabState.Expanded -> 16.dp
}
}
val color by transition.animateColor(
transitionSpec = { tween(400) },
label = "color",
) { state ->
when (state) {
FabState.Collapsed -> MaterialTheme.colorScheme.primary
FabState.Expanded -> MaterialTheme.colorScheme.tertiary
}
}
val iconRotation by transition.animateFloat(
transitionSpec = {
spring(dampingRatio = Spring.DampingRatioMediumBouncy)
},
label = "iconRotation",
) { state ->
when (state) {
FabState.Collapsed -> 0f
FabState.Expanded -> 45f
}
}
// FAB
Box(
modifier = Modifier
.size(size)
.clip(RoundedCornerShape(cornerRadius))
.background(color)
.clickable {
currentState = when (currentState) {
FabState.Collapsed -> FabState.Expanded
FabState.Expanded -> FabState.Collapsed
}
},
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.Add,
contentDescription = "Add",
tint = Color.White,
modifier = Modifier.graphicsLayer { rotationZ = iconRotation },
)
}
}
// ── Conditional transitionSpec ──
// Berbeda spec untuk setiap arah transisi
val alpha by transition.animateFloat(
transitionSpec = {
when {
// Collapsed → Expanded: lambat
FabState.Collapsed isTransitioningTo FabState.Expanded ->
tween(500)
// Expanded → Collapsed: cepat
FabState.Expanded isTransitioningTo FabState.Collapsed ->
tween(200)
else -> snap()
}
},
label = "alpha",
) { state ->
when (state) {
FabState.Collapsed -> 1f
FabState.Expanded -> 0.8f
}
}
7. Animatable (Low-Level)
Animatable adalah API low-level yang memberikan kontrol penuh atas animasi. Berguna untuk gesture-based animation, animation sequences, atau animasi yang memerlukan logic kompleks.
@Composable
fun AnimatableExample() {
val alpha = remember { Animatable(0f) }
val offsetX = remember { Animatable(0f) }
val scale = remember { Animatable(0.5f) }
LaunchedEffect(Unit) {
// ── Sequential animation ──
// 1. Fade in
alpha.animateTo(
targetValue = 1f,
animationSpec = tween(500),
)
// 2. Slide in (setelah fade selesai)
offsetX.animateTo(
targetValue = 0f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
),
)
// 3. Bounce scale
scale.animateTo(
targetValue = 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioHighBouncy,
),
)
// ── Infinite pulsing ──
scale.animateTo(
targetValue = 1.1f,
animationSpec = tween(800),
)
scale.animateTo(
targetValue = 1f,
animationSpec = tween(800),
)
}
// ── Animation with snap ──
LaunchedEffect(Unit) {
// Snap langsung ke nilai tertentu
offsetX.snapTo(-200f)
// Lalu animasi ke posisi
offsetX.animateTo(
targetValue = 0f,
animationSpec = spring(),
)
}
Box(
modifier = Modifier
.graphicsLayer {
this.alpha = alpha.value
translationX = offsetX.value
scaleX = scale.value
scaleY = scale.value
}
.size(120.dp)
.background(Color(0xFF4CAF50), RoundedCornerShape(16.dp)),
contentAlignment = Alignment.Center,
) {
Text("Animatable!", color = Color.White)
}
}
// ── Animatable untuk swipe gesture ──
@Composable
fun SwipeableCard(onDismissed: () -> Unit) {
val offsetX = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
Box(
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
.pointerInput(Unit) {
detectDragGestures(
onDragEnd = {
scope.launch {
if (offsetX.value.absoluteValue > 300f) {
// Swipe keluar layar
offsetX.animateTo(
targetValue = if (offsetX.value > 0) 1500f else -1500f,
animationSpec = tween(300),
)
onDismissed()
} else {
// Snap back
offsetX.animateTo(
targetValue = 0f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
),
)
}
}
},
onDrag = { change, dragAmount ->
change.consume()
scope.launch {
offsetX.snapTo(offsetX.value + dragAmount.x)
}
},
)
}
.background(Color(0xFF2196F3), RoundedCornerShape(16.dp)),
contentAlignment = Alignment.Center,
) {
Text("Geser untuk dismiss", color = Color.White)
}
}
8. Crossfade & Size Animations
8.1 Crossfade
@Composable
fun CrossfadeExample() {
var currentPage by remember { mutableStateOf("home") }
Column {
// Navigation
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf("home" to "🏠", "profile" to "👤", "settings" to "⚙️").forEach { (key, icon) ->
IconButton(onClick = { currentPage = key }) {
Text(icon, fontSize = 24.sp)
}
}
}
// Crossfade animation
Crossfade(
targetState = currentPage,
animationSpec = tween(300),
label = "crossfade",
) { page ->
when (page) {
"home" -> HomePage()
"profile" -> ProfilePage()
"settings" -> SettingsPage()
}
}
}
}
8.2 animateContentSize
@Composable
fun ExpandableText(text: String, maxLines: Int = 3) {
var isExpanded by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxWidth()
.animateContentSize(
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMediumLow,
),
),
) {
Text(
text = text,
maxLines = if (isExpanded) Int.MAX_VALUE else maxLines,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(16.dp),
)
TextButton(
onClick = { isExpanded = !isExpanded },
modifier = Modifier.align(Alignment.CenterHorizontally),
) {
Text(if (isExpanded) "Tutup" else "Baca selengkapnya")
}
}
}
// ── Animated Card Expansion ──
@Composable
fun ExpandableCard(
title: String,
content: String,
) {
var isExpanded by remember { mutableStateOf(false) }
Card(
modifier = Modifier
.fillMaxWidth()
.animateContentSize()
.clickable { isExpanded = !isExpanded },
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(title, style = MaterialTheme.typography.titleMedium)
Icon(
if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null,
)
}
if (isExpanded) {
Spacer(Modifier.height(8.dp))
Text(content, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
9. Animasi + Gesture
@Composable
fun DragWithFlingExample() {
val offsetX = remember { Animatable(0f) }
val offsetY = remember { Animatable(0f) }
val scale = remember { Animatable(1f) }
val scope = rememberCoroutineScope()
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectDragGestures(
onDragStart = {
scope.launch {
scale.animateTo(
1.1f,
spring(dampingRatio = Spring.DampingRatioMediumBouncy),
)
}
},
onDragEnd = {
scope.launch {
scale.animateTo(
1f,
spring(dampingRatio = Spring.DampingRatioMediumBouncy),
)
// Snap back ke center
launch {
offsetX.animateTo(
0f,
spring(dampingRatio = Spring.DampingRatioLowBouncy),
)
}
launch {
offsetY.animateTo(
0f,
spring(dampingRatio = Spring.DampingRatioLowBouncy),
)
}
}
},
onDrag = { change, dragAmount ->
change.consume()
scope.launch {
offsetX.snapTo(offsetX.value + dragAmount.x)
offsetY.snapTo(offsetY.value + dragAmount.y)
}
},
)
},
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.graphicsLayer {
translationX = offsetX.value
translationY = offsetY.value
scaleX = scale.value
scaleY = scale.value
}
.size(120.dp)
.background(
brush = Brush.linearGradient(
colors = listOf(Color(0xFF6200EA), Color(0xFF3700B3)),
),
shape = RoundedCornerShape(24.dp),
),
contentAlignment = Alignment.Center,
) {
Text("Drag Me!", color = Color.White, fontWeight = FontWeight.Bold)
}
}
}
// ── Transformable: Pinch, Pan, Rotate ──
@Composable
fun TransformableBox() {
val scale = remember { Animatable(1f) }
val rotation = remember { Animatable(0f) }
val offsetX = remember { Animatable(0f) }
val offsetY = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, rotate ->
scope.launch {
// Zoom
scale.snapTo((scale.value * zoom).coerceIn(0.5f, 3f))
// Rotate
rotation.snapTo(rotation.value + rotate)
// Pan
offsetX.snapTo(offsetX.value + pan.x)
offsetY.snapTo(offsetY.value + pan.y)
}
}
},
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.graphicsLayer {
scaleX = scale.value
scaleY = scale.value
rotationZ = rotation.value
translationX = offsetX.value
translationY = offsetY.value
}
.size(200.dp)
.background(Color(0xFFFF9800), RoundedCornerShape(16.dp)),
contentAlignment = Alignment.Center,
) {
Text("🔍 Gesture", color = Color.White, fontSize = 20.sp)
}
}
}
10. Best Practices & Performance
- Gunakan label di semua animasi — membantu debugging dan Compose Inspector
- Pilih API yang tepat — gunakan
animate*AsStateuntuk perubahan sederhana,updateTransitionuntuk multi-state,Animatableuntuk gesture - Hindari animasi di recomposition — gunakan
graphicsLayer{}untuk transformasi (opacity, scale, rotation, translation) - Gunakan
animateContentSize()— untuk perubahan ukuran yang halus (expandable cards, collapsible text) - Gunakan
spring()— sebagai default spec; lebih natural daritween() - Jangan terlalu banyak animasi — fungsionalitas lebih penting dari eye candy
- Gunakan
LaunchedEffect— untuk memulai animasi berdasarkan event (bukan setiap recomposition) - Test di device fisik — performa animasi berbeda antara emulator dan device asli
- Animasi
graphicsLayer(opacity, scale, rotation, translation) TIDAK memicu recomposition — sangat efisien - Animasi yang mengubah
Modifier.size()atauModifier.offset()BISA memicu recomposition — gunakanModifier.offset{}dengan lambda untuk layout-level animation - Hindari membuat
Animatabledi dalam composable tanparemember - Gunakan
derivedStateOfuntuk mengurangi recomposition saat ada banyak state yang berubah
11. Quiz Pemahaman
Uji pemahaman Anda tentang Jetpack Compose Animations: