Jetpack Compose: Zaawansowane Animacje Krok po Kroku

Kompletny przewodnik po zaawansowanych animacjach Compose: przejścia, AnimatedVisibility, Animatable, gesty i wydajność płynnych interfejsów Android.

Zaawansowane animacje Jetpack Compose dla deweloperów Android

Animacje przekształcają funkcjonalną aplikację w niezapomniane doświadczenie użytkownika. Jetpack Compose oferuje potężne deklaratywne API animacji, które znacząco upraszcza tworzenie płynnych interfejsów. Ten przewodnik omawia zaawansowane techniki budowania wydajnych i łatwych w utrzymaniu animacji.

Wymagania wstępne

Ten tutorial zakłada znajomość podstaw Compose (rekompozycja, stan, modyfikatory). Aby przypomnieć sobie podstawy, warto najpierw zajrzeć do przewodnika z pytaniami rekrutacyjnymi o Jetpack Compose.

Podstawy API Animacji w Compose

Compose udostępnia kilka poziomów API dla animacji. Wybór zależy od pożądanego poziomu kontroli i złożoności animacji.

API dzieli się na trzy główne kategorie: animacje wysokiego poziomu (AnimatedVisibility, AnimatedContent), animacje oparte na stanie (animate*AsState) oraz animacje niskiego poziomu (Animatable, Transition).

AnimationLevels.ktkotlin
// Overview of the three animation API levels
@Composable
fun AnimationApiOverview() {
    // High level: simple predefined animations
    AnimatedVisibility(visible = isVisible) {
        Text("Animated content")
    }

    // Intermediate level: state-driven animation
    val alpha by animateFloatAsState(
        targetValue = if (isSelected) 1f else 0.5f,
        label = "alpha"
    )

    // Low level: full control over animation
    val animatable = remember { Animatable(0f) }
    LaunchedEffect(targetValue) {
        animatable.animateTo(targetValue)
    }
}

Wybór odpowiedniego poziomu API jest kluczowy, aby zachować czytelny kod przy zachowaniu niezbędnej elastyczności.

AnimatedVisibility: Eleganckie Animacje Pojawiania i Znikania

AnimatedVisibility to idealny punkt wejścia do animowania pojawiania się i znikania elementów. To API automatycznie zarządza komponowaniem i dekomponowaniem treści.

Parametry enter i exit przyjmują kombinacje przejść definiujących zachowanie animacji. Przejścia te można łączyć operatorem +.

AnimatedVisibilityExample.ktkotlin
@Composable
fun ExpandableCard(
    title: String,
    content: String,
    modifier: Modifier = Modifier
) {
    var isExpanded by remember { mutableStateOf(false) }

    Card(
        modifier = modifier.clickable { isExpanded = !isExpanded }
    ) {
        Column(modifier = Modifier.padding(16.dp)) {
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween
            ) {
                Text(text = title, style = MaterialTheme.typography.titleMedium)
                Icon(
                    imageVector = if (isExpanded) Icons.Default.ExpandLess
                                  else Icons.Default.ExpandMore,
                    contentDescription = null
                )
            }

            // Expansion animation with fade + slide
            AnimatedVisibility(
                visible = isExpanded,
                enter = fadeIn(animationSpec = tween(300)) +
                        expandVertically(animationSpec = tween(300)),
                exit = fadeOut(animationSpec = tween(200)) +
                       shrinkVertically(animationSpec = tween(200))
            ) {
                Text(
                    text = content,
                    modifier = Modifier.padding(top = 12.dp),
                    style = MaterialTheme.typography.bodyMedium
                )
            }
        }
    }
}

Treść wewnątrz AnimatedVisibility jest komponowana tylko gdy visible = true, co optymalizuje wydajność list z wieloma rozwijanymi elementami.

Łączenie przejść

Dostępne przejścia obejmują fadeIn/fadeOut, slideIn/slideOut, expandIn/shrinkOut, scaleIn/scaleOut. Można je dowolnie łączyć, tworząc niestandardowe efekty.

animate*AsState: Animacje Sterowane Stanem

Rodzina funkcji animate*AsState automatycznie animuje zmiany wartości prymitywnych. To najbardziej idiomatyczne podejście do prostych animacji w Compose.

Każdy typ danych ma dedykowaną funkcję: animateColorAsState, animateFloatAsState, animateDpAsState, animateIntAsState itd.

AnimateAsStateExample.ktkotlin
@Composable
fun InteractiveButton(
    isSelected: Boolean,
    onClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    // Animated background color
    val backgroundColor by animateColorAsState(
        targetValue = if (isSelected) MaterialTheme.colorScheme.primary
                      else MaterialTheme.colorScheme.surfaceVariant,
        animationSpec = tween(durationMillis = 250),
        label = "backgroundColor"
    )

    // Animated elevation
    val elevation by animateDpAsState(
        targetValue = if (isSelected) 8.dp else 2.dp,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness = Spring.StiffnessLow
        ),
        label = "elevation"
    )

    // Animated text size
    val textSize by animateFloatAsState(
        targetValue = if (isSelected) 18f else 14f,
        label = "textSize"
    )

    Surface(
        modifier = modifier.clickable(onClick = onClick),
        color = backgroundColor,
        shadowElevation = elevation,
        shape = RoundedCornerShape(12.dp)
    ) {
        Text(
            text = if (isSelected) "Selected" else "Select",
            modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp),
            fontSize = textSize.sp
        )
    }
}

Parametr animationSpec kontroluje czasowe zachowanie animacji. Dwie najczęściej używane specyfikacje to tween (ustalony czas trwania z easingiem) i spring (realistyczna fizyka z odbiciem).

Transition: Orkiestracja Wielu Animacji

Gdy kilka właściwości musi być animowanych w skoordynowany sposób, updateTransition zapewnia scentralizowaną kontrolę. To API gwarantuje, że wszystkie animacje pozostaną zsynchronizowane.

Wzorzec polega na zdefiniowaniu stanu enum, a następnie utworzeniu animacji dla każdej właściwości zależnej od tego stanu.

TransitionExample.ktkotlin
// Card state: defines visual behavior
enum class CardState { Collapsed, Expanded, Selected }

@Composable
fun AnimatedStateCard(
    cardState: CardState,
    modifier: Modifier = Modifier
) {
    // Central transition coordinating all animations
    val transition = updateTransition(
        targetState = cardState,
        label = "cardTransition"
    )

    // Card height based on state
    val cardHeight by transition.animateDp(
        transitionSpec = { spring(stiffness = Spring.StiffnessLow) },
        label = "height"
    ) { state ->
        when (state) {
            CardState.Collapsed -> 80.dp
            CardState.Expanded -> 200.dp
            CardState.Selected -> 160.dp
        }
    }

    // Border color based on state
    val borderColor by transition.animateColor(
        transitionSpec = { tween(300) },
        label = "borderColor"
    ) { state ->
        when (state) {
            CardState.Collapsed -> Color.Transparent
            CardState.Expanded -> MaterialTheme.colorScheme.outline
            CardState.Selected -> MaterialTheme.colorScheme.primary
        }
    }

    // Corner radius based on state
    val cornerRadius by transition.animateDp(
        label = "cornerRadius"
    ) { state ->
        when (state) {
            CardState.Collapsed -> 8.dp
            CardState.Expanded -> 16.dp
            CardState.Selected -> 24.dp
        }
    }

    Card(
        modifier = modifier
            .height(cardHeight)
            .border(2.dp, borderColor, RoundedCornerShape(cornerRadius)),
        shape = RoundedCornerShape(cornerRadius)
    ) {
        // Card content
    }
}

Gotowy na rozmowy o Android?

Ćwicz z naszymi interaktywnymi symulatorami, flashcards i testami technicznymi.

Animatable: Pełna Kontrola nad Animacją

Animatable to API niskiego poziomu zapewniające pełną kontrolę programistyczną. Takie podejście jest niezbędne dla animacji przerywalnych, gestów lub złożonych scenariuszy.

W przeciwieństwie do animate*AsState, Animatable umożliwia zatrzymanie, odwrócenie lub zmodyfikowanie trwającej animacji bez czekania na jej zakończenie.

AnimatableExample.ktkotlin
@Composable
fun SwipeableCard(
    onDismiss: () -> Unit,
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    // Horizontal offset controlled by Animatable
    val offsetX = remember { Animatable(0f) }
    val scope = rememberCoroutineScope()

    // Swipe threshold to trigger dismissal
    val dismissThreshold = 300f

    Box(
        modifier = modifier
            .offset { IntOffset(offsetX.value.roundToInt(), 0) }
            .pointerInput(Unit) {
                detectHorizontalDragGestures(
                    onDragEnd = {
                        scope.launch {
                            if (abs(offsetX.value) > dismissThreshold) {
                                // Animate out then callback
                                val target = if (offsetX.value > 0) 1000f else -1000f
                                offsetX.animateTo(
                                    targetValue = target,
                                    animationSpec = tween(200)
                                )
                                onDismiss()
                            } else {
                                // Return to initial position with spring
                                offsetX.animateTo(
                                    targetValue = 0f,
                                    animationSpec = spring(
                                        dampingRatio = Spring.DampingRatioMediumBouncy
                                    )
                                )
                            }
                        }
                    },
                    onHorizontalDrag = { _, dragAmount ->
                        scope.launch {
                            // snapTo for instant finger tracking
                            offsetX.snapTo(offsetX.value + dragAmount)
                        }
                    }
                )
            }
    ) {
        content()
    }
}

Kluczowe metody Animatable to animateTo() (animacja do celu), snapTo() (natychmiastowa zmiana) i stop() (przerwanie).

AnimatedContent: Przejścia Treści

AnimatedContent animuje przejścia między różnymi treściami. To API jest idealne dla zmian stanu, które całkowicie modyfikują wyświetlany interfejs.

Klucz targetState określa, kiedy ma nastąpić przejście. transitionSpec definiuje sposób interakcji wychodzącej i przychodzącej treści.

AnimatedContentExample.ktkotlin
@Composable
fun CounterWithAnimation(
    count: Int,
    modifier: Modifier = Modifier
) {
    AnimatedContent(
        targetState = count,
        modifier = modifier,
        transitionSpec = {
            // Determine animation direction
            val direction = if (targetState > initialState) {
                // New number enters from top
                slideInVertically { height -> -height } + fadeIn() togetherWith
                slideOutVertically { height -> height } + fadeOut()
            } else {
                // New number enters from bottom
                slideInVertically { height -> height } + fadeIn() togetherWith
                slideOutVertically { height -> -height } + fadeOut()
            }
            direction.using(SizeTransform(clip = false))
        },
        label = "counter"
    ) { targetCount ->
        Text(
            text = "$targetCount",
            style = MaterialTheme.typography.displayLarge,
            fontWeight = FontWeight.Bold
        )
    }
}
Wydajność z AnimatedContent

Treść wewnątrz AnimatedContent jest rekomponowana przy każdej zmianie targetState. Dla złożonej treści warto memoizować kosztowne elementy lub używać odpowiedniej strategii kluczy.

Nieskończone Animacje z rememberInfiniteTransition

Dla animacji w pętli (wskaźniki ładowania, efekty pulsujące) rememberInfiniteTransition udostępnia dedykowane API, które nie wymaga ręcznego zarządzania cyklem.

InfiniteTransitionExample.ktkotlin
@Composable
fun PulsingDot(
    color: Color = MaterialTheme.colorScheme.primary,
    modifier: Modifier = Modifier
) {
    val infiniteTransition = rememberInfiniteTransition(label = "pulse")

    // Looping scale animation
    val scale by infiniteTransition.animateFloat(
        initialValue = 0.8f,
        targetValue = 1.2f,
        animationSpec = infiniteRepeatable(
            animation = tween(600, easing = FastOutSlowInEasing),
            repeatMode = RepeatMode.Reverse
        ),
        label = "scale"
    )

    // Synchronized opacity animation
    val alpha by infiniteTransition.animateFloat(
        initialValue = 0.5f,
        targetValue = 1f,
        animationSpec = infiniteRepeatable(
            animation = tween(600, easing = FastOutSlowInEasing),
            repeatMode = RepeatMode.Reverse
        ),
        label = "alpha"
    )

    Box(
        modifier = modifier
            .size(24.dp)
            .scale(scale)
            .alpha(alpha)
            .background(color = color, shape = CircleShape)
    )
}

Animacje List z LazyColumn

Animacje elementów list wymagają szczególnej uwagi. Modyfikator animateItem() (wcześniej animateItemPlacement) automatycznie animuje zmiany kolejności.

ListAnimationExample.ktkotlin
@Composable
fun AnimatedTaskList(
    tasks: List<Task>,
    onToggle: (Task) -> Unit,
    onDelete: (Task) -> Unit,
    modifier: Modifier = Modifier
) {
    LazyColumn(
        modifier = modifier,
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        items(
            items = tasks,
            key = { it.id }  // Stable key required for animateItem
        ) { task ->
            var isVisible by remember { mutableStateOf(true) }

            // Exit animation before deletion
            AnimatedVisibility(
                visible = isVisible,
                exit = shrinkVertically() + fadeOut()
            ) {
                TaskItem(
                    task = task,
                    onToggle = { onToggle(task) },
                    onDelete = {
                        isVisible = false
                        // Delay to let animation complete
                    },
                    modifier = Modifier.animateItem(
                        fadeInSpec = tween(300),
                        fadeOutSpec = tween(300),
                        placementSpec = spring(
                            dampingRatio = Spring.DampingRatioMediumBouncy,
                            stiffness = Spring.StiffnessLow
                        )
                    )
                )
            }

            // Trigger deletion after animation
            LaunchedEffect(isVisible) {
                if (!isVisible) {
                    delay(300)
                    onDelete(task)
                }
            }
        }
    }
}
Stabilne klucze dla animacji list

Bez stabilnego parametru key, animateItem nie potrafi śledzić elementów między rekompozycjami. Lepiej użyć unikalnego ID niż indeksu listy.

Animacje Canvas

Dla niestandardowych efektów wizualnych połączenie Canvas z animowanymi wartościami zapewnia pełną elastyczność.

CanvasAnimationExample.ktkotlin
@Composable
fun AnimatedProgressRing(
    progress: Float,  // 0f to 1f
    modifier: Modifier = Modifier
) {
    // Progress animation with spring for natural feel
    val animatedProgress by animateFloatAsState(
        targetValue = progress,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioLowBouncy,
            stiffness = Spring.StiffnessVeryLow
        ),
        label = "progress"
    )

    // Continuous rotation animation
    val infiniteTransition = rememberInfiniteTransition(label = "rotation")
    val rotation by infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = 360f,
        animationSpec = infiniteRepeatable(
            animation = tween(2000, easing = LinearEasing)
        ),
        label = "rotation"
    )

    val primaryColor = MaterialTheme.colorScheme.primary
    val trackColor = MaterialTheme.colorScheme.surfaceVariant

    Canvas(
        modifier = modifier
            .size(120.dp)
            .rotate(rotation)
    ) {
        val strokeWidth = 12.dp.toPx()
        val radius = (size.minDimension - strokeWidth) / 2

        // Background circle (track)
        drawCircle(
            color = trackColor,
            radius = radius,
            style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
        )

        // Animated progress arc
        drawArc(
            color = primaryColor,
            startAngle = -90f,
            sweepAngle = animatedProgress * 360f,
            useCenter = false,
            style = Stroke(width = strokeWidth, cap = StrokeCap.Round),
            topLeft = Offset(strokeWidth / 2, strokeWidth / 2),
            size = Size(radius * 2, radius * 2)
        )
    }
}

Gotowy na rozmowy o Android?

Ćwicz z naszymi interaktywnymi symulatorami, flashcards i testami technicznymi.

Optymalizacja Wydajności Animacji

Źle zoptymalizowane animacje mogą powodować jank i wyczerpywać baterię. Oto dobre praktyki, aby utrzymać 60 FPS.

Pierwsza zasada to unikanie alokacji podczas animacji. Lepiej użyć graphicsLayer zamiast modyfikatorów wywołujących rekompozycje.

PerformanceOptimization.ktkotlin
@Composable
fun OptimizedAnimatedCard(
    isExpanded: Boolean,
    modifier: Modifier = Modifier
) {
    val scale by animateFloatAsState(
        targetValue = if (isExpanded) 1.1f else 1f,
        label = "scale"
    )

    val alpha by animateFloatAsState(
        targetValue = if (isExpanded) 1f else 0.8f,
        label = "alpha"
    )

    Card(
        modifier = modifier
            // ✅ graphicsLayer: GPU modifications without recomposition
            .graphicsLayer {
                scaleX = scale
                scaleY = scale
                this.alpha = alpha
            }
        // ❌ Avoid: .scale(scale).alpha(alpha)
        // These modifiers trigger recompositions
    ) {
        Text("Card content")
    }
}

// Example with remembered lambda to avoid allocations
@Composable
fun OptimizedClickableItem(
    onClick: () -> Unit,
    content: @Composable () -> Unit
) {
    // ✅ Stable remembered lambda
    val interactionSource = remember { MutableInteractionSource() }

    Box(
        modifier = Modifier
            .clickable(
                interactionSource = interactionSource,
                indication = ripple(),
                onClick = onClick
            )
    ) {
        content()
    }
}

Drugi krytyczny punkt dotyczy animacji w listach. Należy ograniczyć liczbę jednoczesnych animacji i używać derivedStateOf dla obliczeń pochodnych.

ListPerformance.ktkotlin
@Composable
fun PerformantAnimatedList(
    items: List<Item>,
    modifier: Modifier = Modifier
) {
    // Calculate once whether the list is empty
    val isEmpty by remember {
        derivedStateOf { items.isEmpty() }
    }

    LazyColumn(modifier = modifier) {
        items(
            items = items,
            key = { it.id }
        ) { item ->
            // Lightweight animation only on initial appearance
            var hasAppeared by remember { mutableStateOf(false) }

            LaunchedEffect(Unit) {
                hasAppeared = true
            }

            val alpha by animateFloatAsState(
                targetValue = if (hasAppeared) 1f else 0f,
                animationSpec = tween(200),
                label = "itemAlpha"
            )

            ItemCard(
                item = item,
                modifier = Modifier.graphicsLayer { this.alpha = alpha }
            )
        }
    }
}

Podsumowanie

Animacje w Jetpack Compose oferują równowagę między łatwością użycia a zaawansowaną kontrolą. Oto kluczowe punkty do zapamiętania:

  • ✅ Wybierać odpowiedni poziom API w zależności od złożoności (AnimatedVisibility → animate*AsState → Animatable)
  • ✅ Używać updateTransition do koordynacji wielu powiązanych animacji
  • ✅ Preferować spring dla naturalnych animacji i tween dla precyzyjnych czasów
  • ✅ Zawsze podawać stabilny parametr key dla animacji list
  • ✅ Optymalizować z graphicsLayer, aby unikać niepotrzebnych rekompozycji
  • ✅ Testować animacje na rzeczywistych urządzeniach, aby zweryfikować wydajność

Opanowanie animacji Compose wyróżnia profesjonalne aplikacje Android. Te techniki, połączone z dbałością o wydajność, pozwalają tworzyć płynne i angażujące doświadczenia użytkownika.

Zacznij ćwiczyć!

Sprawdź swoją wiedzę z naszymi symulatorami rozmów i testami technicznymi.

Tagi

#jetpack compose
#android
#animations
#kotlin
#ui

Udostępnij

Powiązane artykuły