Android Development

Jetpack Compose Animations: Panduan Lengkap

Tutorial lengkap animasi di Jetpack Compose — animate*AsState, updateTransition, AnimatedVisibility, Animatable, animation specs, dan pola animasi untuk UI yang hidup

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-levelAnimatedVisibility, AnimatedContent, Crossfade🟢 MudahShow/hide, page transition, content swap
Mid-levelanimate*AsState, updateTransition🟡 SedangColor, size, offset changes, multi-state animation
Low-levelAnimatable, AnimationState🔴 LanjutanCustom gesture animation, physics, sequential
Diagram: Compose Animation Hierarchy
┌─────────────────────────────────────────────────────────────┐
│                    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

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

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

Kotlin — animateFloatAsState (Opacity, Scale, Rotation)
@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

FungsiTipeUntuk
animateFloatAsStateFloatOpacity, scale, rotation, offset
animateDpAsStateDpSize, padding, offset, corner radius
animateColorAsStateColorBackground, text color, border color
animateIntAsStateIntBadge count, step indicator
animateIntOffsetAsStateIntOffsetPosition offset
animateSizeAsStateSizeWidth & height simultaneously
animateRectAsStateRectRectangle bounds
animateOffsetAsStateOffset2D position

3. Animation Specs

Animation Spec menentukan bagaimana animasi berjalan — durasi, easing, fisika, dll.

3.1 Jenis Animation Spec

Kotlin — Animation Specs
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

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

Kotlin — Custom 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 TransitionExit TransitionDeskripsi
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.

Kotlin — AnimatedContent
@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

Kotlin — SizeTransform Options
// ── 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).

Kotlin — updateTransition
// ── 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.

Kotlin — Animatable
@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

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

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

Kotlin — Drag & Fling Gesture Animation
@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

✅ Best Practices Compose Animations
  • Gunakan label di semua animasi — membantu debugging dan Compose Inspector
  • Pilih API yang tepat — gunakan animate*AsState untuk perubahan sederhana, updateTransition untuk multi-state, Animatable untuk 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 dari tween()
  • 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
⚠️ Performance Tips
  • Animasi graphicsLayer (opacity, scale, rotation, translation) TIDAK memicu recomposition — sangat efisien
  • Animasi yang mengubah Modifier.size() atau Modifier.offset() BISA memicu recomposition — gunakan Modifier.offset{} dengan lambda untuk layout-level animation
  • Hindari membuat Animatable di dalam composable tanpa remember
  • Gunakan derivedStateOf untuk mengurangi recomposition saat ada banyak state yang berubah

11. Quiz Pemahaman

Uji pemahaman Anda tentang Jetpack Compose Animations:

Pertanyaan 1: Kapan menggunakan animate*AsState vs updateTransition?

a) Selalu gunakan updateTransition
b) animate*AsState untuk satu nilai, updateTransition untuk banyak nilai yang berubah bersamaan
c) animate*AsState lebih cepat
d) Tidak ada perbedaan

Pertanyaan 2: Apa yang dilakukan AnimatedVisibility?

a) Mengubah visibility element secara mendadak
b) Menganimasikan masuk/keluar-nya komponen saat visibility berubah
c) Menghapus komponen dari tree
d) Mengubah alpha menjadi 0

Pertanyaan 3: Mengapa graphicsLayer lebih efisien dari Modifier.size() untuk animasi?

a) Karena graphicsLayer menggunakan GPU
b) Karena graphicsLayer TIDAK memicu recomposition — hanya repaint
c) Karena graphicsLayer lebih mudah ditulis
d) Karena graphicsLayer mendukung lebih banyak property

Pertanyaan 4: Apa fungsi SizeTransform di AnimatedContent?

a) Mengubah font size
b) Menganimasikan perubahan ukuran container saat konten berubah
c) Mengatur batas ukuran
d) Mengoptimasi ukuran bitmap

Pertanyaan 5: Kapan menggunakan Animatable alih-alih animate*AsState?

a) Selalu
b) Saat memerlukan kontrol penuh: gesture-based, sequential, atau conditional animation
c) Hanya untuk iOS-like animations
d) Saat ingin animasi lebih cepat
🔍 Zoom
100%
🎨 Tema