Jetpack Compose: Просунуті анімації крок за кроком

Повний гід з просунутих анімацій Compose: переходи, AnimatedVisibility, Animatable, жести та продуктивність для плавних інтерфейсів Android.

Просунуті анімації Jetpack Compose для розробників Android

Анімації перетворюють функціональний застосунок на запам'ятовуваний користувацький досвід. Jetpack Compose пропонує потужний декларативний API анімацій, який значно спрощує створення плавних інтерфейсів. Цей гід розглядає просунуті техніки створення продуктивних і підтримуваних анімацій.

Передумови

Цей урок передбачає знайомство з основами Compose (рекомпозиція, стан, модифікатори). Для опанування основ варто спершу ознайомитися з гідом запитань на співбесіді з Jetpack Compose.

Основи API анімацій у Compose

Compose надає кілька рівнів API для анімацій. Вибір залежить від бажаного рівня контролю та складності анімації.

API поділяється на три основні категорії: високорівневі анімації (AnimatedVisibility, AnimatedContent), анімації на основі стану (animate*AsState) і низькорівневі анімації (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)
    }
}

Вибір правильного рівня API критично важливий для збереження читабельного коду без втрати потрібної гнучкості.

AnimatedVisibility: Витончені анімації появи та зникнення

AnimatedVisibility — ідеальна точка входу для анімації появи та зникнення елементів. Цей API автоматично керує композицією та декомпозицією контенту.

Параметри enter і exit приймають комбінації переходів, що визначають поведінку анімації. Ці переходи можна поєднувати оператором +.

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

Контент усередині AnimatedVisibility компонується лише коли visible = true, що оптимізує продуктивність списків з багатьма розгортуваними елементами.

Поєднання переходів

Доступні переходи включають fadeIn/fadeOut, slideIn/slideOut, expandIn/shrinkOut, scaleIn/scaleOut. Їх можна вільно комбінувати для створення власних ефектів.

animate*AsState: Анімації, керовані станом

Родина функцій animate*AsState автоматично анімує зміни примітивних значень. Це найідіоматичніший підхід для простих анімацій у Compose.

Кожен тип даних має свою функцію: animateColorAsState, animateFloatAsState, animateDpAsState, animateIntAsState тощо.

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

Параметр animationSpec керує часовою поведінкою анімації. Дві найвживаніші специфікації: tween (фіксована тривалість з easing) і spring (реалістична фізика з підстрибуванням).

Transition: Оркестрування кількох анімацій

Коли кілька властивостей мають анімуватися узгоджено, updateTransition забезпечує централізований контроль. Цей API гарантує, що всі анімації залишаться синхронізованими.

Патерн полягає у визначенні enum-стану та подальшому створенні анімацій для кожної властивості, яка залежить від цього стану.

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

Готовий до співбесід з Android?

Практикуйся з нашими інтерактивними симуляторами, flashcards та технічними тестами.

Animatable: Повний контроль над анімацією

Animatable — це низькорівневий API, що забезпечує повний програмний контроль. Цей підхід необхідний для перериваних анімацій, жестів або складних сценаріїв.

На відміну від animate*AsState, Animatable дає змогу зупинити, обернути або змінити поточну анімацію без очікування її завершення.

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

Ключові методи Animatable: animateTo() (анімувати до цілі), snapTo() (миттєва зміна) і stop() (переривання).

AnimatedContent: Переходи контенту

AnimatedContent анімує переходи між різним контентом. Цей API ідеально підходить для змін стану, які повністю змінюють відображуваний UI.

Ключ targetState визначає, коли має відбутися перехід. transitionSpec визначає, як взаємодіють вихідний і вхідний контент.

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
        )
    }
}
Продуктивність із AnimatedContent

Контент усередині AnimatedContent рекомпонується при кожній зміні targetState. Для складного контенту варто мемоїзувати дорогі елементи або використовувати відповідну стратегію ключів.

Нескінченні анімації з rememberInfiniteTransition

Для циклічних анімацій (індикатори завантаження, пульсуючі ефекти) rememberInfiniteTransition надає окремий API, який не вимагає ручного керування циклом.

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

Анімації списків із LazyColumn

Анімації елементів списку потребують особливої уваги. Модифікатор animateItem() (раніше animateItemPlacement) автоматично анімує перевпорядкування.

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)
                }
            }
        }
    }
}
Стабільні ключі для анімацій списків

Без стабільного параметра key animateItem не може відстежувати елементи між рекомпозиціями. Краще використати унікальний ID, а не індекс списку.

Анімації Canvas

Для власних візуальних ефектів поєднання Canvas з анімованими значеннями забезпечує повну гнучкість.

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

Готовий до співбесід з Android?

Практикуйся з нашими інтерактивними симуляторами, flashcards та технічними тестами.

Оптимізація продуктивності анімацій

Погано оптимізовані анімації можуть викликати jank і виснажувати батарею. Ось найкращі практики для підтримання 60 FPS.

Перше правило — уникати алокацій під час анімації. Замість модифікаторів, які запускають рекомпозиції, варто використовувати graphicsLayer.

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

Друга важлива точка стосується анімацій у списках. Слід обмежувати кількість одночасних анімацій і використовувати derivedStateOf для похідних обчислень.

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

Висновок

Анімації в Jetpack Compose забезпечують баланс між простотою використання та просунутим контролем. Ключові висновки:

  • ✅ Обирати рівень API відповідно до складності (AnimatedVisibility → animate*AsState → Animatable)
  • ✅ Використовувати updateTransition для координації кількох пов'язаних анімацій
  • ✅ Віддавати перевагу spring для природних анімацій і tween для точних тривалостей
  • ✅ Завжди надавати стабільний параметр key для анімацій списків
  • ✅ Оптимізувати з graphicsLayer, щоб уникнути зайвих рекомпозицій
  • ✅ Тестувати анімації на реальних пристроях для підтвердження продуктивності

Володіння анімаціями Compose вирізняє професійні Android-застосунки. Ці техніки в поєднанні з увагою до продуктивності дають змогу створювати плавний і захопливий користувацький досвід.

Починай практикувати!

Перевір свої знання з нашими симуляторами співбесід та технічними тестами.

Теги

#jetpack compose
#android
#animations
#kotlin
#ui

Поділитися

Пов'язані статті