Jetpack Compose: Hoạt ảnh nâng cao từng bước

Hướng dẫn đầy đủ về hoạt ảnh nâng cao trong Compose: chuyển tiếp, AnimatedVisibility, Animatable, cử chỉ và hiệu năng cho giao diện Android mượt mà.

Hoạt ảnh nâng cao của Jetpack Compose dành cho lập trình viên Android

Hoạt ảnh biến một ứng dụng có chức năng thành một trải nghiệm đáng nhớ. Jetpack Compose cung cấp một API hoạt ảnh khai báo mạnh mẽ giúp đơn giản hóa đáng kể việc tạo các giao diện mượt mà. Hướng dẫn này khám phá các kỹ thuật nâng cao để xây dựng hoạt ảnh hiệu quả và dễ bảo trì.

Yêu cầu trước tiên

Hướng dẫn này giả định rằng người đọc đã quen với các kiến thức nền tảng của Compose (recomposition, state, modifier). Để ôn lại kiến thức cơ bản, hãy tham khảo trước hướng dẫn câu hỏi phỏng vấn về Jetpack Compose.

Nền tảng API Hoạt ảnh trong Compose

Compose cung cấp nhiều cấp độ API cho hoạt ảnh. Lựa chọn phụ thuộc vào mức độ kiểm soát mong muốn và độ phức tạp của hoạt ảnh.

API được chia thành ba danh mục chính: hoạt ảnh cấp cao (AnimatedVisibility, AnimatedContent), hoạt ảnh dựa trên state (animate*AsState) và hoạt ảnh cấp thấp (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)
    }
}

Việc lựa chọn cấp độ API phù hợp là yếu tố then chốt để giữ mã dễ đọc trong khi vẫn duy trì sự linh hoạt cần thiết.

AnimatedVisibility: Hoạt ảnh xuất hiện và biến mất tinh tế

AnimatedVisibility là điểm khởi đầu lý tưởng để tạo hoạt ảnh cho việc xuất hiện và biến mất của các phần tử. API này tự động quản lý việc tạo và hủy thành phần.

Các tham số enterexit chấp nhận các tổ hợp chuyển tiếp xác định hành vi của hoạt ảnh. Các chuyển tiếp này có thể được kết hợp bằng toán tử +.

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

Nội dung bên trong AnimatedVisibility chỉ được tạo khi visible = true, giúp tối ưu hóa hiệu năng cho các danh sách có nhiều phần tử có thể mở rộng.

Kết hợp các chuyển tiếp

Các chuyển tiếp khả dụng bao gồm fadeIn/fadeOut, slideIn/slideOut, expandIn/shrinkOut, scaleIn/scaleOut. Chúng có thể được kết hợp tự do để tạo ra các hiệu ứng tùy chỉnh.

animate*AsState: Hoạt ảnh dựa trên State

Họ hàm animate*AsState tự động tạo hoạt ảnh cho các thay đổi của các giá trị nguyên thủy. Đây là cách tiếp cận đặc trưng nhất cho các hoạt ảnh đơn giản trong Compose.

Mỗi kiểu dữ liệu có hàm chuyên dụng riêng: animateColorAsState, animateFloatAsState, animateDpAsState, animateIntAsState, v.v.

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

Tham số animationSpec kiểm soát hành vi thời gian của hoạt ảnh. Hai spec được sử dụng phổ biến nhất là tween (thời lượng cố định kèm easing) và spring (vật lý thực tế kèm hiệu ứng nảy).

Transition: Điều phối nhiều hoạt ảnh

Khi nhiều thuộc tính cần được tạo hoạt ảnh một cách phối hợp, updateTransition cung cấp khả năng kiểm soát tập trung. API này đảm bảo tất cả các hoạt ảnh luôn đồng bộ.

Mẫu này bao gồm việc định nghĩa một state dạng enum, sau đó tạo hoạt ảnh cho mỗi thuộc tính phụ thuộc vào state đó.

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

Sẵn sàng chinh phục phỏng vấn Android?

Luyện tập với mô phỏng tương tác, flashcards và bài kiểm tra kỹ thuật.

Animatable: Kiểm soát toàn diện hoạt ảnh

Animatable là API cấp thấp cung cấp khả năng kiểm soát lập trình hoàn toàn. Cách tiếp cận này cần thiết cho các hoạt ảnh có thể bị gián đoạn, các cử chỉ hoặc các kịch bản phức tạp.

Khác với animate*AsState, Animatable cho phép dừng, đảo ngược hoặc chỉnh sửa một hoạt ảnh đang chạy mà không cần đợi nó kết thúc.

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

Các phương thức quan trọng của AnimatableanimateTo() (tạo hoạt ảnh đến mục tiêu), snapTo() (thay đổi tức thì) và stop() (gián đoạn).

AnimatedContent: Chuyển tiếp nội dung

AnimatedContent tạo hoạt ảnh cho các chuyển tiếp giữa các nội dung khác nhau. API này hoàn hảo cho những thay đổi state làm thay đổi hoàn toàn UI hiển thị.

Khóa targetState xác định khi nào cần xảy ra chuyển tiếp. transitionSpec định nghĩa cách nội dung đi ra và đi vào tương tác với nhau.

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
        )
    }
}
Hiệu năng với AnimatedContent

Nội dung bên trong AnimatedContent được tái cấu thành mỗi khi targetState thay đổi. Đối với nội dung phức tạp, nên ghi nhớ các phần tử tốn kém hoặc sử dụng chiến lược khóa phù hợp.

Hoạt ảnh vô hạn với rememberInfiniteTransition

Đối với hoạt ảnh lặp (chỉ báo tải, hiệu ứng nhịp đập), rememberInfiniteTransition cung cấp một API chuyên dụng không yêu cầu quản lý chu kỳ thủ công.

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

Hoạt ảnh danh sách với LazyColumn

Hoạt ảnh cho các phần tử danh sách đòi hỏi sự chú ý đặc biệt. Modifier animateItem() (trước đây là animateItemPlacement) tự động tạo hoạt ảnh cho việc sắp xếp lại.

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)
                }
            }
        }
    }
}
Khóa ổn định cho hoạt ảnh danh sách

Không có tham số key ổn định, animateItem không thể theo dõi các phần tử giữa các lần tái cấu thành. Nên dùng ID duy nhất thay vì chỉ số danh sách.

Hoạt ảnh với Canvas

Đối với các hiệu ứng hình ảnh tùy chỉnh, kết hợp Canvas với các giá trị hoạt ảnh mang lại sự linh hoạt hoàn toàn.

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

Sẵn sàng chinh phục phỏng vấn Android?

Luyện tập với mô phỏng tương tác, flashcards và bài kiểm tra kỹ thuật.

Tối ưu hóa hiệu năng hoạt ảnh

Hoạt ảnh không được tối ưu có thể gây jank và làm hao pin. Đây là những thực hành tốt nhất để duy trì 60 FPS.

Quy tắc đầu tiên là tránh cấp phát trong khi hoạt ảnh đang chạy. Nên dùng graphicsLayer thay vì các modifier kích hoạt tái cấu thành.

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

Điểm quan trọng thứ hai liên quan đến hoạt ảnh trong danh sách. Cần giới hạn số lượng hoạt ảnh đồng thời và sử dụng derivedStateOf cho các phép tính dẫn xuất.

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

Kết luận

Hoạt ảnh trong Jetpack Compose mang đến sự cân bằng giữa dễ sử dụng và kiểm soát nâng cao. Đây là những điểm chính cần ghi nhớ:

  • ✅ Lựa chọn cấp độ API phù hợp với độ phức tạp (AnimatedVisibility → animate*AsState → Animatable)
  • ✅ Sử dụng updateTransition để điều phối nhiều hoạt ảnh liên quan
  • ✅ Ưu tiên spring cho hoạt ảnh tự nhiên và tween cho thời lượng chính xác
  • ✅ Luôn cung cấp tham số key ổn định cho hoạt ảnh danh sách
  • ✅ Tối ưu bằng graphicsLayer để tránh tái cấu thành không cần thiết
  • ✅ Kiểm tra hoạt ảnh trên thiết bị thật để xác nhận hiệu năng

Thành thạo hoạt ảnh Compose tạo nên sự khác biệt cho các ứng dụng Android chuyên nghiệp. Những kỹ thuật này, kết hợp với sự chú ý đến hiệu năng, cho phép tạo ra trải nghiệm người dùng mượt mà và hấp dẫn.

Bắt đầu luyện tập!

Kiểm tra kiến thức với mô phỏng phỏng vấn và bài kiểm tra kỹ thuật.

Thẻ

#jetpack compose
#android
#animations
#kotlin
#ui

Chia sẻ

Bài viết liên quan