Jetpack Compose: แอนิเมชันขั้นสูงทีละขั้นตอน

คู่มือฉบับสมบูรณ์เกี่ยวกับแอนิเมชันขั้นสูงใน Compose: ทรานซิชัน AnimatedVisibility, Animatable, ท่าทาง และประสิทธิภาพสำหรับอินเทอร์เฟซ Android ที่ลื่นไหล

แอนิเมชันขั้นสูงของ Jetpack Compose สำหรับนักพัฒนา Android

แอนิเมชันเปลี่ยนแอปพลิเคชันเชิงฟังก์ชันให้กลายเป็นประสบการณ์ผู้ใช้ที่น่าจดจำ Jetpack Compose มี API แอนิเมชันแบบประกาศที่ทรงพลังซึ่งช่วยให้การสร้างอินเทอร์เฟซที่ลื่นไหลง่ายขึ้นอย่างมาก คู่มือนี้สำรวจเทคนิคขั้นสูงในการสร้างแอนิเมชันที่มีประสิทธิภาพและบำรุงรักษาง่าย

ข้อกำหนดเบื้องต้น

บทแนะนำนี้สันนิษฐานว่าผู้อ่านคุ้นเคยกับพื้นฐานของ Compose (recomposition, state, modifier) สำหรับพื้นฐานควรอ่านคู่มือคำถามสัมภาษณ์ 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 ควบคุมพฤติกรรมเชิงเวลาของแอนิเมชัน Spec ที่ใช้บ่อยที่สุดสองรายการคือ 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 จะถูก recompose ทุกครั้งที่ 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

แอนิเมชันสำหรับรายการต้องให้ความสำคัญเป็นพิเศษ Modifier 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 จะไม่สามารถติดตามอิลิเมนต์ระหว่างการ recompose ได้ ควรใช้ 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 แทน modifier ที่กระตุ้นการ recompose

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 เพื่อหลีกเลี่ยงการ recompose ที่ไม่จำเป็น
  • ✅ ทดสอบแอนิเมชันบนอุปกรณ์จริงเพื่อยืนยันประสิทธิภาพ

การเชี่ยวชาญแอนิเมชัน Compose ทำให้แอป Android ระดับมืออาชีพแตกต่างจากที่อื่น เทคนิคเหล่านี้ผสมผสานกับการให้ความสำคัญกับประสิทธิภาพ ทำให้สามารถสร้างประสบการณ์ผู้ใช้ที่ลื่นไหลและน่าสนใจ

เริ่มฝึกซ้อมเลย!

ทดสอบความรู้ของคุณด้วยตัวจำลองสัมภาษณ์และแบบทดสอบเทคนิคครับ

แท็ก

#jetpack compose
#android
#animations
#kotlin
#ui

แชร์

บทความที่เกี่ยวข้อง