Animasi

Jetpack Compose menyediakan API yang andal dan dapat diperluas yang memudahkan penerapan berbagai animasi di UI aplikasi Anda. Dokumen ini menjelaskan cara menggunakan API ini serta API yang akan digunakan sesuai skenario animasi Anda.

Ringkasan

Animasi sangat penting dalam aplikasi seluler modern untuk menghadirkan pengalaman pengguna yang lancar dan dapat dipahami. Banyak Jetpack Compose Animation API tersedia sebagai fungsi composable, seperti tata letak dan elemen UI lainnya, dan didukung oleh API tingkat rendah yang dibuat dengan fungsi penangguhan coroutine Kotlin. Panduan ini dimulai dengan API tingkat tinggi yang berguna dalam banyak skenario praktis, dan selanjutnya menjelaskan API tingkat rendah yang memberi Anda kontrol dan penyesuaian lebih lanjut.

Diagram di bawah ini membantu Anda menentukan API apa yang akan digunakan untuk menerapkan animasi.

  • Jika Anda menganimasikan perubahan konten dalam tata letak:
    • Jika Anda menganimasikan pemunculan dan penghilangan:
      • Gunakan AnimationVisibility.
    • Menukar konten berdasarkan status:
      • Jika Anda melakukan crossfading konten:
        • Gunakan Crossfade.
      • Jika tidak, gunakan AnimatedContent.
    • Jika tidak, gunakan Modifier.contentSize.
  • Jika animasi berbasis status:
    • Jika animasi terjadi selama komposisi:
      • Jika animasi tidak terbatas:
        • Gunakan rememberInfiniteTransition.
      • Jika Anda menganimasikan beberapa nilai secara bersamaan:
        • Gunakan updateTransition.
      • Jika tidak, gunakan animate*AsState.
  • Jika Anda ingin memiliki kontrol yang baik terhadap waktu animasi:
    • Gunakan Animation.
  • Jika animasi adalah satu-satunya sumber ketepatan
    • Gunakan Animatable.
  • Jika tidak, gunakan AnimationState atau animate.

Diagram alir yang menjelaskan pohon keputusan untuk memilih API animasi yang sesuai

API animasi tingkat tinggi

Compose menawarkan API animasi tingkat tinggi untuk beberapa pola animasi umum yang digunakan di banyak aplikasi. API ini disesuaikan agar sesuai dengan praktik terbaik Gerakan Desain Material.

AnimatedVisibility (eksperimental)

Composable AnimatedVisibility menganimasikan muncul dan hilangnya kontennya.

var editable by remember { mutableStateOf(true) }
AnimatedVisibility(visible = editable) {
    Text(text = "Edit")
}

Secara default, konten muncul dengan memudar dan meluas, serta menghilang dengan memudar dan menyusut. Transisi dapat disesuaikan dengan menentukan EnterTransition dan ExitTransition.

var visible by remember { mutableStateOf(true) }
val density = LocalDensity.current
AnimatedVisibility(
    visible = visible,
    enter = slideInVertically(
        // Slide in from 40 dp from the top.
        initialOffsetY = { with(density) { -40.dp.roundToPx() } }
    ) + expandVertically(
        // Expand from the top.
        expandFrom = Alignment.Top
    ) + fadeIn(
        // Fade in with the initial alpha of 0.3f.
        initialAlpha = 0.3f
    ),
    exit = slideOutVertically() + shrinkVertically() + fadeOut()
) {
    Text("Hello", Modifier.fillMaxWidth().height(200.dp))
}

Seperti yang Anda lihat pada contoh di atas, Anda dapat menggabungkan beberapa objek EnterTransition atau ExitTransition dengan operator +, dan masing-masing menerima parameter opsional untuk menyesuaikan perilakunya. Lihat referensi untuk informasi selengkapnya.

Contoh EnterTransition

fadeIn:

slideIn:

slideInHorizontally:

slideInVertically:

scaleIn:

expandIn:

expandHorizontally:

expandVertically:

Contoh ExitTransition

fadeOut:

slideOut:

slideOutHorizontally:

slideOutVertically:

scaleOut:

shrinkOut:

shrinkHorizontally:

shrinkVertically:

AnimatedVisibility juga menawarkan varian yang memerlukan MutableTransitionState. Hal ini memungkinkan Anda untuk memicu animasi segera setelah AnimatedVisibility ditambahkan ke hierarki komposisi. Hal ini juga berguna untuk mengamati status animasi.

// Create a MutableTransitionState<Boolean> for the AnimatedVisibility.
val state = remember {
    MutableTransitionState(false).apply {
        // Start the animation immediately.
        targetState = true
    }
}
Column {
    AnimatedVisibility(visibleState = state) {
        Text(text = "Hello, world!")
    }

    // Use the MutableTransitionState to know the current animation state
    // of the AnimatedVisibility.
    Text(
        text = when {
            state.isIdle && state.currentState -> "Visible"
            !state.isIdle && state.currentState -> "Disappearing"
            state.isIdle && !state.currentState -> "Invisible"
            else -> "Appearing"
        }
    )
}

Menganimasikan masuk dan keluar untuk turunan

Konten dalam AnimatedVisibility (turunan langsung atau tidak langsung) dapat menggunakan pengubah animateEnterExit untuk menentukan perilaku animasi yang berbeda bagi setiap turunan. Efek visual untuk setiap turunan ini adalah kombinasi animasi yang ditetapkan pada composable AnimatedVisibility serta animasi masuk dan keluar turunan itu sendiri.

AnimatedVisibility(
    visible = visible,
    // Fade in/out the background and the foreground.
    enter = fadeIn(),
    exit = fadeOut()
) {
    Box(Modifier.fillMaxSize().background(Color.DarkGray)) {
        Box(
            Modifier
                .align(Alignment.Center)
                .animateEnterExit(
                    // Slide in/out the inner box.
                    enter = slideInVertically(),
                    exit = slideOutVertically()
                )
                .sizeIn(minWidth = 256.dp, minHeight = 64.dp)
                .background(Color.Red)
        ) {
            // Content of the notification…
        }
    }
}

Dalam beberapa kasus, Anda mungkin ingin AnimatedVisibility tidak menerapkan animasi sama sekali sehingga turunan dapat memiliki animasinya sendiri yang berbeda dengan animateEnterExit. Untuk mencapai hal ini, tentukan EnterTransition.None dan ExitTransition.None pada composable AnimatedVisibility.

Menambahkan animasi kustom

Jika Anda ingin menambahkan efek animasi kustom di luar animasi masuk dan keluar bawaan, akses instance Transition dasar melalui properti transition di dalam lambda konten untuk AnimatedVisibility. Semua status animasi yang ditambahkan ke instance Transisi akan berjalan bersamaan dengan animasi masuk dan keluar dari AnimatedVisibility. AnimatedVisibility menunggu hingga semua animasi di Transition selesai sebelum menghapus kontennya. Untuk animasi keluar yang dibuat terpisah dari Transition (seperti menggunakan animate*AsState), AnimatedVisibility tidak akan dapat memperhitungkannya, oleh karena itu dapat menghapus composable konten sebelum selesai.

AnimatedVisibility(
    visible = visible,
    enter = fadeIn(),
    exit = fadeOut()
) { // this: AnimatedVisibilityScope
    // Use AnimatedVisibilityScope.transition() to add a custom animation
    // to the AnimatedVisibility.
    val background by transition.animateColor { state ->
        if (state == EnterExitState.Visible) Color.Blue else Color.Gray
    }
    Box(modifier = Modifier.size(128.dp).background(background))
}

Lihat updateTransition untuk mengetahui detail tentang Transition.

AnimatedContent (eksperimental)

Composable AnimatedContent akan menganimasikan kontennya saat komposisi tersebut berubah berdasarkan status target.

Row {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) {
        Text("Add")
    }
    AnimatedContent(targetState = count) { targetCount ->
        // Make sure to use `targetCount`, not `count`.
        Text(text = "Count: $targetCount")
    }
}

Perhatikan bahwa Anda harus selalu menggunakan parameter lambda dan mencerminkannya ke konten. API menggunakan nilai ini sebagai kunci untuk mengidentifikasi konten yang saat ini ditampilkan.

Secara default, konten awal akan memudar, lalu konten target akan makin jelas (perilaku ini disebut memudar). Anda dapat menyesuaikan perilaku animasi ini dengan menentukan objek ContentTransform ke parameter transitionSpec. Anda dapat membuat ContentTransform dengan mengombinasikan EnterTransition dengan ExitTransition menggunakan fungsi infix with. Anda dapat menerapkan SizeTransform ke ContentTransform dengan melampirkannya dengan fungsi infix using.

AnimatedContent(
    targetState = count,
    transitionSpec = {
        // Compare the incoming number with the previous number.
        if (targetState > initialState) {
            // If the target number is larger, it slides up and fades in
            // while the initial (smaller) number slides up and fades out.
            slideInVertically({ height -> height }) + fadeIn() with
                slideOutVertically({ height -> -height }) + fadeOut()
        } else {
            // If the target number is smaller, it slides down and fades in
            // while the initial number slides down and fades out.
            slideInVertically({ height -> -height }) + fadeIn() with
                slideOutVertically({ height -> height }) + fadeOut()
        }.using(
            // Disable clipping since the faded slide-in/out should
            // be displayed out of bounds.
            SizeTransform(clip = false)
        )
    }
) { targetCount ->
    Text(text = "$targetCount")
}

EnterTransition akan menentukan cara konten target akan muncul, dan ExitTransition akan menentukan cara konten awal akan menghilang. Selain semua fungsi EnterTransition dan ExitTransition yang tersedia untuk AnimatedVisibility, AnimatedContent menawarkan slideIntoContainer dan slideOutOfContainer. Fungsi ini merupakan alternatif yang praktis untuk slideInHorizontally/Vertically dan slideOutHorizontally/Vertically yang menghitung jarak slide berdasarkan pada ukuran konten awal dan konten target dari konten AnimatedContent.

SizeTransform menentukan cara ukuran akan dianimasikan antara konten awal dan target. Anda memiliki akses ke ukuran awal dan ukuran target saat membuat animasi. SizeTransform juga mengontrol apakah konten harus dipotong ke ukuran komponen selama animasi.

var expanded by remember { mutableStateOf(false) }
Surface(
    color = MaterialTheme.colors.primary,
    onClick = { expanded = !expanded }
) {
    AnimatedContent(
        targetState = expanded,
        transitionSpec = {
            fadeIn(animationSpec = tween(150, 150)) with
                fadeOut(animationSpec = tween(150)) using
                SizeTransform { initialSize, targetSize ->
                    if (targetState) {
                        keyframes {
                            // Expand horizontally first.
                            IntSize(targetSize.width, initialSize.height) at 150
                            durationMillis = 300
                        }
                    } else {
                        keyframes {
                            // Shrink vertically first.
                            IntSize(initialSize.width, targetSize.height) at 150
                            durationMillis = 300
                        }
                    }
                }
        }
    ) { targetExpanded ->
        if (targetExpanded) {
            Expanded()
        } else {
            ContentIcon()
        }
    }
}

Menganimasikan tombol masuk/keluar untuk turunan

Sama seperti AnimatedVisibility, pengubah animateEnterExit tersedia di dalam lambda konten AnimatedContent. Gunakan ini untuk menerapkan EnterAnimation dan ExitAnimation ke setiap turunan langsung atau tidak langsung secara terpisah.

Menambahkan animasi kustom

Sama seperti AnimatedVisibility, kolom transition tersedia di dalam lambda konten AnimatedContent. Gunakan ini untuk membuat efek animasi kustom yang berjalan secara bersamaan dengan transisi AnimatedContent. Lihat updateTransition untuk mengetahui detailnya.

animateContentSize

Pengubah animateContentSize menganimasikan perubahan ukuran.

var message by remember { mutableStateOf("Hello") }
Box(
    modifier = Modifier.background(Color.Blue).animateContentSize()
) {
    Text(text = message)
}

Crossfade

Crossfade membuat animasi di antara dua tata letak dengan animasi crossfade. Dengan mengganti nilai yang diteruskan ke parameter current, konten dialihkan dengan animasi crossfade.

var currentPage by remember { mutableStateOf("A") }
Crossfade(targetState = currentPage) { screen ->
    when (screen) {
        "A" -> Text("Page A")
        "B" -> Text("Page B")
    }
}

API Animasi tingkat rendah

Semua API animasi tingkat tinggi yang disebutkan di bagian sebelumnya dibuat berdasarkan fondasi API animasi tingkat rendah.

Fungsi animate*AsState adalah API yang paling sederhana, yang merender perubahan nilai instan sebagai nilai animasi. Ini didukung oleh Animatable, yang merupakan API berbasis coroutine untuk menganimasikan nilai tunggal. updateTransition membuat objek transisi yang dapat mengelola beberapa nilai animasi dan menjalankannya berdasarkan perubahan status. rememberInfiniteTransition serupa, tetapi membuat transisi tidak terbatas yang dapat mengelola beberapa animasi yang terus berjalan tanpa batas. Semua API ini merupakan composable kecuali Animatable, yang berarti animasi ini dapat dibuat di luar komposisi.

Semua API ini didasarkan pada API Animation yang lebih mendasar. Meskipun sebagian besar aplikasi tidak akan berinteraksi langsung dengan Animation, beberapa kemampuan penyesuaian untuk Animation tersedia melalui API tingkat yang lebih tinggi. Lihat Menyesuaikan animasi untuk mengetahui informasi selengkapnya tentang AnimationVector dan AnimationSpec.

Diagram yang menunjukkan hubungan antara berbagai API animasi tingkat rendah

animate*AsState

Fungsi animate*AsState adalah API animasi yang paling sederhana di Compose untuk menganimasikan satu nilai. Anda hanya memberikan nilai akhir (atau nilai target), dan API akan memulai animasi dari nilai saat ini ke nilai yang ditentukan.

Berikut contoh menganimasikan alfa menggunakan API ini. Hanya dengan menggabungkan nilai target di animateFloatAsState, nilai alfa sekarang menjadi nilai animasi di antara nilai yang disediakan (1f maupun 0.5f dalam kasus ini).

val alpha: Float by animateFloatAsState(if (enabled) 1f else 0.5f)
Box(
    Modifier.fillMaxSize()
        .graphicsLayer(alpha = alpha)
        .background(Color.Red)
)

Perhatikan bahwa Anda tidak perlu membuat instance dari kelas animasi, atau menangani gangguan. Di balik layar, objek animasi (yaitu, instance Animatable) akan dibuat dan diingat di situs panggilan, dengan nilai target pertama sebagai nilai awal. Sejak saat itu, setiap kali Anda memberikan nilai target yang berbeda untuk composable ini, animasi akan otomatis dimulai terhadap nilai tersebut. Jika saat ini sudah ada animasi, animasi dimulai dari nilai saat ini (dan kecepatannya) dan bergerak menuju nilai target. Selama animasi, composable ini dapat dikomposisi ulang dan menampilkan nilai animasi yang diupdate setiap frame.

Secara mandiri, Compose menyediakan fungsi animate*AsState untuk Float, Color, Dp, Size, Offset, Rect, Int, IntOffset, dan IntSize. Anda dapat dengan mudah menambahkan dukungan untuk jenis data lainnya dengan memberikan TwoWayConverter ke animateValueAsState yang menggunakan jenis umum.

Anda dapat menyesuaikan spesifikasi animasi dengan menyediakan AnimationSpec. Lihat AnimationSpec untuk informasi selengkapnya.

Animatable

Animatable adalah pemegang nilai yang dapat menganimasikan nilai karena diubah melalui animateTo. Ini adalah API yang mencadangkan penerapan animate*AsState. Ini memastikan kelanjutan yang konsisten dan pengalaman eksklusif, yang berarti bahwa perubahan nilai selalu berkelanjutan dan setiap animasi yang sedang berlangsung akan dibatalkan.

Banyak fitur Animatable, termasuk animateTo, disediakan sebagai fungsi penangguhan. Ini berarti bahwa fitur-fitur tersebut harus digabungkan dalam cakupan coroutine yang sesuai. Misalnya, Anda dapat menggunakan composable LaunchedEffect untuk membuat cakupan hanya selama durasi nilai kunci yang ditentukan.

// Start out gray and animate to green/red based on `ok`
val color = remember { Animatable(Color.Gray) }
LaunchedEffect(ok) {
    color.animateTo(if (ok) Color.Green else Color.Red)
}
Box(Modifier.fillMaxSize().background(color.value))

Pada contoh di atas, kita membuat dan mengingat instance Animatable dengan nilai awal Color.Gray. Bergantung pada nilai flag boolean ok, warna animasi ke Color.Green atau Color.Red. Setiap perubahan berikutnya pada nilai boolean akan memulai animasi ke warna lainnya. Jika ada animasi yang berlangsung saat nilai diubah, animasi akan dibatalkan, dan animasi baru dimulai dari nilai snapshot saat ini dengan kecepatan saat ini.

Ini adalah implementasi animasi yang mencadangkan API animate*AsState yang disebutkan di bagian sebelumnya. Dibandingkan dengan animate*AsState, menggunakan Animatable secara langsung akan memberi kita kontrol yang lebih mendetail. Pertama, Animatable dapat memiliki nilai awal yang berbeda dari nilai target pertamanya. Misalnya, contoh kode di atas menampilkan kotak abu-abu terlebih dahulu, yang segera mulai beranimasi menjadi hijau atau merah. Kedua, Animatable memberikan lebih banyak operasi pada nilai konten, yaitu snapTo dan animateDecay. snapTo segera menyetel nilai saat ini ke nilai target. Hal ini berguna saat animasi itu sendiri bukan satu-satunya sumber ketepatan dan harus disinkronkan dengan status lain, seperti peristiwa sentuh. animateDecay memulai animasi yang melambat dari kecepatan yang ditentukan. Ini berguna untuk menerapkan perilaku fling. Lihat Gestur dan animasi untuk informasi selengkapnya.

Secara mandiri, Animatable mendukung Float dan Color, namun semua jenis data dapat digunakan dengan menyediakan TwoWayConverter. Lihat AnimationVector untuk mengetahui informasi selengkapnya.

Anda dapat menyesuaikan spesifikasi animasi dengan menyediakan AnimationSpec. Lihat AnimationSpec untuk informasi selengkapnya.

updateTransition

Transition mengelola satu atau beberapa animasi sebagai turunannya dan menjalankannya secara bersamaan di beberapa status.

Status dapat berupa jenis data apa pun. Dalam banyak kasus, Anda dapat menggunakan jenis enum kustom untuk memastikan keamanan jenis, seperti dalam contoh berikut:

private enum class BoxState {
    Collapsed,
    Expanded
}

updateTransition membuat dan mengingat instance Transition dan memperbarui statusnya.

var currentState by remember { mutableStateOf(BoxState.Collapsed) }
val transition = updateTransition(currentState)

Anda kemudian dapat menggunakan salah satu dari fungsi ekstensi animate* untuk menentukan animasi turunan dalam transisi ini. Menentukan nilai target untuk setiap status. Fungsi animate* ini menampilkan nilai animasi yang diperbarui setiap frame selama animasi saat status transisi diperbarui dengan updateTransition.

val rect by transition.animateRect { state ->
    when (state) {
        BoxState.Collapsed -> Rect(0f, 0f, 100f, 100f)
        BoxState.Expanded -> Rect(100f, 100f, 300f, 300f)
    }
}
val borderWidth by transition.animateDp { state ->
    when (state) {
        BoxState.Collapsed -> 1.dp
        BoxState.Expanded -> 0.dp
    }
}

Secara opsional, Anda dapat meneruskan parameter transitionSpec untuk menentukan AnimationSpec yang berbeda untuk setiap kombinasi perubahan status transisi. Lihat AnimationSpec untuk mengetahui informasi selengkapnya.

val color by transition.animateColor(
    transitionSpec = {
        when {
            BoxState.Expanded isTransitioningTo BoxState.Collapsed ->
                spring(stiffness = 50f)
            else ->
                tween(durationMillis = 500)
        }
    }
) { state ->
    when (state) {
        BoxState.Collapsed -> MaterialTheme.colors.primary
        BoxState.Expanded -> MaterialTheme.colors.background
    }
}

Setelah transisi mencapai status target, Transition.currentState akan sama dengan Transition.targetState. Ini dapat digunakan sebagai sinyal apakah transisi telah selesai.

Kita terkadang ingin memiliki status awal yang berbeda dengan status target pertama. Kita dapat menggunakan updateTransition dengan MutableTransitionState untuk mencapainya. Misalnya, memungkinkan kita memulai animasi segera setelah kode memasuki komposisi.

// Start in collapsed state and immediately animate to expanded
var currentState = remember { MutableTransitionState(BoxState.Collapsed) }
currentState.targetState = BoxState.Expanded
val transition = updateTransition(currentState)
// ...

Untuk transisi lebih kompleks yang melibatkan beberapa fungsi composable, Anda dapat menggunakan createChildTransition untuk membuat transisi turunan. Teknik ini berguna untuk memisahkan masalah di antara beberapa subkomponen dalam composable yang kompleks. Transisi induk akan memperhatikan semua nilai animasi di transisi turunan.

enum class DialerState { DialerMinimized, NumberPad }

@Composable
fun DialerButton(isVisibleTransition: Transition<Boolean>) {
    // `isVisibleTransition` spares the need for the content to know
    // about other DialerStates. Instead, the content can focus on
    // animating the state change between visible and not visible.
}

@Composable
fun NumberPad(isVisibleTransition: Transition<Boolean>) {
    // `isVisibleTransition` spares the need for the content to know
    // about other DialerStates. Instead, the content can focus on
    // animating the state change between visible and not visible.
}

@Composable
fun Dialer(dialerState: DialerState) {
    val transition = updateTransition(dialerState)
    Box {
        // Creates separate child transitions of Boolean type for NumberPad
        // and DialerButton for any content animation between visible and
        // not visible
        NumberPad(
            transition.createChildTransition {
                it == DialerState.NumberPad
            }
        )
        DialerButton(
            transition.createChildTransition {
                it == DialerState.DialerMinimized
            }
        )
    }
}

Menggunakan transisi dengan AnimatedVisibility dan AnimatedContent

AnimatedVisibility dan AnimatedContent tersedia sebagai fungsi ekstensi dari Transition. targetState untuk Transition.AnimatedVisibility dan Transition.AnimatedContent berasal dari Transition, dan memicu transisi masuk/keluar sesuai kebutuhan saat targetState Transition telah berubah. Fungsi ekstensi ini memungkinkan semua animasi enter/exit/sizeTransform yang akan bersifat internal ke AnimatedVisibility/AnimatedContent diangkat ke dalam Transition. Dengan fungsi ekstensi ini, perubahan status AnimatedVisibility/AnimatedContent dapat diamati dari luar. Sebagai ganti parameter visible boolean, versi AnimatedVisibility ini menggunakan lambda yang mengonversi status target transisi induk ke dalam boolean.

Lihat AnimatedVisibility dan AnimatedContent untuk mengetahui detailnya.

var selected by remember { mutableStateOf(false) }
// Animates changes when `selected` is changed.
val transition = updateTransition(selected)
val borderColor by transition.animateColor { isSelected ->
    if (isSelected) Color.Magenta else Color.White
}
val elevation by transition.animateDp { isSelected ->
    if (isSelected) 10.dp else 2.dp
}
Surface(
    onClick = { selected = !selected },
    shape = RoundedCornerShape(8.dp),
    border = BorderStroke(2.dp, borderColor),
    elevation = elevation
) {
    Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
        Text(text = "Hello, world!")
        // AnimatedVisibility as a part of the transition.
        transition.AnimatedVisibility(
            visible = { targetSelected -> targetSelected },
            enter = expandVertically(),
            exit = shrinkVertically()
        ) {
            Text(text = "It is fine today.")
        }
        // AnimatedContent as a part of the transition.
        transition.AnimatedContent { targetState ->
            if (targetState) {
                Text(text = "Selected")
            } else {
                Icon(imageVector = Icons.Default.Phone, contentDescription = "Phone")
            }
        }
    }
}

Melakukan enkapsulasi Transisi dan membuatnya dapat digunakan kembali

Untuk kasus penggunaan sederhana, menentukan animasi transisi dalam composable yang sama dengan UI adalah opsi yang sangat valid. Namun, saat Anda mengerjakan komponen yang rumit dengan sejumlah nilai animasi, sebaiknya pisahkan penerapan animasi dari composable UI.

Anda dapat melakukannya dengan membuat class yang menyimpan semua nilai animasi dan fungsi 'update' yang menampilkan instance class tersebut. Penerapan transisi dapat diekstrak ke fungsi terpisah yang baru. Pola ini berguna saat ada kebutuhan untuk memusatkan logika animasi, atau membuat animasi yang rumit dapat digunakan kembali.

enum class BoxState { Collapsed, Expanded }

@Composable
fun AnimatingBox(boxState: BoxState) {
    val transitionData = updateTransitionData(boxState)
    // UI tree
    Box(
        modifier = Modifier
            .background(transitionData.color)
            .size(transitionData.size)
    )
}

// Holds the animation values.
private class TransitionData(
    color: State<Color>,
    size: State<Dp>
) {
    val color by color
    val size by size
}

// Create a Transition and return its animation values.
@Composable
private fun updateTransitionData(boxState: BoxState): TransitionData {
    val transition = updateTransition(boxState)
    val color = transition.animateColor { state ->
        when (state) {
            BoxState.Collapsed -> Color.Gray
            BoxState.Expanded -> Color.Red
        }
    }
    val size = transition.animateDp { state ->
        when (state) {
            BoxState.Collapsed -> 64.dp
            BoxState.Expanded -> 128.dp
        }
    }
    return remember(transition) { TransitionData(color, size) }
}

Dukungan alat

Android Studio mendukung pemeriksaan Transisi di Pratinjau Compose.

  • Pratinjau transisi frame by frame
  • Pemeriksaan nilai untuk semua animasi dalam transisi
  • Pratinjau transisi antara status awal dan target

Saat memulai pemeriksa animasi, Anda akan melihat panel "Animasi" di bawah pratinjau interaktif tempat Anda dapat menjalankan transisi yang disertakan dalam pratinjau. Transisi serta setiap nilai animasinya diberi label dengan nama default. Anda dapat menyesuaikan label dengan menentukan parameter label di updateTransition dan fungsi animate*. Untuk mempelajari Pratinjau Compose lebih lanjut, lihat Pratinjau tata letak.

rememberInfiniteTransition

InfiniteTransition memiliki satu atau beberapa animasi turunan seperti Transition, tetapi animasi akan mulai berjalan segera setelah dimasukkan ke dalam komposisi dan tidak berhenti kecuali dihapus. Anda dapat membuat instance InfiniteTransition dengan rememberInfiniteTransition. Animasi turunan dapat ditambahkan dengan animateColor, animatedFloat, atau animatedValue. Anda juga perlu menentukan infiniteRepeatable untuk menentukan spesifikasi animasi.

val infiniteTransition = rememberInfiniteTransition()
val color by infiniteTransition.animateColor(
    initialValue = Color.Red,
    targetValue = Color.Green,
    animationSpec = infiniteRepeatable(
        animation = tween(1000, easing = LinearEasing),
        repeatMode = RepeatMode.Reverse
    )
)

Box(Modifier.fillMaxSize().background(color))

TargetBasedAnimation

TargetBasedAnimation adalah API Animasi level terendah yang pernah kami lihat sejauh ini. API lain mencakup sebagian besar kasus penggunaan, namun penggunaan TargetBasedAnimation secara langsung memungkinkan Anda mengontrol waktu pemutaran animasi. Pada contoh di bawah, waktu pemutaran TargetAnimation dikontrol secara manual berdasarkan waktu render frame yang disediakan oleh withFrameMillis.

val anim = remember {
    TargetBasedAnimation(
        animationSpec = tween(200),
        typeConverter = Float.VectorConverter,
        initialValue = 200f,
        targetValue = 1000f
    )
}
var playTime by remember { mutableStateOf(0L) }

LaunchedEffect(anim) {
    val startTime = withFrameNanos { it }

    do {
        playTime = withFrameNanos { it } - startTime
        val animationValue = anim.getValueFromNanos(playTime)
    } while (someCustomCondition())
}

Menyesuaikan animasi

Banyak API Animasi biasanya menerima parameter untuk menyesuaikan perilakunya.

AnimationSpec

Sebagian besar API animasi memungkinkan developer menyesuaikan spesifikasi animasi dengan parameter AnimationSpec opsional.

val alpha: Float by animateFloatAsState(
    targetValue = if (enabled) 1f else 0.5f,
    // Configure the animation duration and easing.
    animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)
)

Ada berbagai jenis AnimationSpec untuk membuat berbagai jenis animasi.

musim semi

spring membuat animasi berbasis fisika antara nilai awal dan akhir. Dibutuhkan 2 parameter: dampingRatio dan stiffness.

dampingRatio menentukan seberapa guncangan seharusnya. Nilai defaultnya adalah Spring.DampingRatioNoBouncy.

Gambar animasi yang menunjukkan perilaku rasio peredaman yang berbeda

stiffness menentukan seberapa cepat spring akan bergerak menuju nilai akhir. Nilai defaultnya adalah Spring.StiffnessMedium.

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = spring(
        dampingRatio = Spring.DampingRatioHighBouncy,
        stiffness = Spring.StiffnessMedium
    )
)

spring dapat menangani gangguan secara lebih lancar daripada jenis AnimationSpec berbasis durasi karena dapat menjamin kontinuitas saat target berubah di tengah animasi. spring digunakan sebagai AnimationSpec default oleh banyak API animasi, seperti animate*AsState dan updateTransition.

tween

tween menganimasikan antara nilai awal dan akhir pada durationMillis yang ditentukan menggunakan kurva easing. Lihat Easing untuk mengetahui informasi selengkapnya. Anda juga dapat menentukan delayMillis untuk menunda dimulainya animasi.

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = tween(
        durationMillis = 300,
        delayMillis = 50,
        easing = LinearOutSlowInEasing
    )
)

keyframe

keyframes dianimasikan berdasarkan nilai snapshot yang ditentukan pada stempel waktu yang berbeda dalam durasi animasi. Pada waktu tertentu, nilai animasi akan diinterpolasi antara dua nilai keyframe. Untuk setiap keyframe ini, Easing dapat ditentukan untuk menentukan kurva interpolasi.

Menentukan nilai pada 0 md dan pada waktu durasi bersifat opsional. Jika Anda tidak menentukan nilai ini, nilai default akan ditetapkan ke nilai awal dan akhir animasi.

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = keyframes {
        durationMillis = 375
        0.0f at 0 with LinearOutSlowInEasing // for 0-15 ms
        0.2f at 15 with FastOutLinearInEasing // for 15-75 ms
        0.4f at 75 // ms
        0.4f at 225 // ms
    }
)

dapat diulang

repeatable menjalankan animasi berbasis durasi (seperti tween atau keyframes) berulang kali hingga mencapai jumlah iterasi yang ditentukan. Anda dapat meneruskan parameter repeatMode untuk menentukan apakah animasi harus diulang dengan memulai dari awal (RepeatMode.Restart) atau dari akhir (RepeatMode.Reverse).

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = repeatable(
        iterations = 3,
        animation = tween(durationMillis = 300),
        repeatMode = RepeatMode.Reverse
    )
)

infiniteRepeatable

infiniteRepeatable seperti repeatable, tetapi berulang untuk jumlah iterasi yang tak terbatas.

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = infiniteRepeatable(
        animation = tween(durationMillis = 300),
        repeatMode = RepeatMode.Reverse
    )
)

Dalam pengujian menggunakan ComposeTestRule, animasi yang menggunakan infiniteRepeatable tidak dijalankan. Komponen akan dirender menggunakan nilai awal setiap nilai animasi.

snap

snap adalah AnimationSpec khusus yang langsung mengalihkan nilai ke nilai akhir. Anda dapat menentukan delayMillis untuk menunda awal animasi.

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = snap(delayMillis = 50)
)

Easing

Operasi AnimationSpec berbasis durasi (seperti tween atau keyframes) menggunakan Easing untuk menyesuaikan fraksi animasi. Dengan cara ini, nilai animasi dapat dipercepat dan diperlambat, dibandingkan bergerak pada tingkat konstan. Fraksi adalah nilai antara 0 (awal) dan 1,0 (akhir) yang menunjukkan titik saat ini dalam animasi.

Easing sebenarnya merupakan fungsi yang mengambil nilai fraksi antara 0 dan 1,0 dan menampilkan float. Nilai yang ditampilkan dapat berada di luar batas untuk mewakili overshoot atau undershoot. Easing kustom dapat dibuat seperti kode di bawah.

val CustomEasing = Easing { fraction -> fraction * fraction }

@Composable
fun EasingUsage() {
    val value by animateFloatAsState(
        targetValue = 1f,
        animationSpec = tween(
            durationMillis = 300,
            easing = CustomEasing
        )
    )
    // … …
}

Compose menyediakan beberapa fungsi Easing bawaan yang mencakup sebagian besar kasus penggunaan. Lihat Kecepatan - Desain Material untuk mengetahui informasi selengkapnya tentang Easing yang akan digunakan bergantung pada skenario Anda.

  • FastOutSlowInEasing
  • LinearOutSlowInEasing
  • FastOutLinearEasing
  • LinearEasing
  • CubicBezierEasing

AnimationVector

Sebagian besar Compose API animasi mendukung Float, Color, Dp, dan jenis data dasar lainnya sebagai nilai animasi secara mandiri, tetapi terkadang Anda perlu menganimasikan jenis data lain termasuk yang khusus. Selama animasi, nilai animasi apa pun akan direpresentasikan sebagai AnimationVector. Nilai ini dikonversi menjadi AnimationVector dan sebaliknya oleh TwoWayConverter yang terkait sehingga sistem animasi inti dapat menanganinya secara seragam. Misalnya, Int direpresentasikan sebagai AnimationVector1D yang memiliki satu nilai float. TwoWayConverter untuk Int terlihat seperti ini:

val IntToVector: TwoWayConverter<Int, AnimationVector1D> =
    TwoWayConverter({ AnimationVector1D(it.toFloat()) }, { it.value.toInt() })

Color pada dasarnya adalah kumpulan 4 nilai, merah, hijau, biru, dan alfa, sehingga Color dikonversi menjadi AnimationVector4D yang menyimpan 4 nilai float. Dengan cara ini, setiap jenis data yang digunakan dalam animasi dikonversi menjadi AnimationVector1D, AnimationVector2D, AnimationVector3D, atau AnimationVector4D bergantung pada dimensinya. Hal ini memungkinkan berbagai komponen objek dianimasikan secara independen, masing-masing dengan pelacakan kecepatannya sendiri. Converter bawaan untuk jenis data dasar dapat diakses menggunakan Color.VectorConverter, Dp.VectorConverter, dan seterusnya.

Jika ingin menambahkan dukungan untuk jenis data baru sebagai nilai animasi, Anda dapat membuat TwoWayConverter Anda sendiri dan menyediakannya ke API. Misalnya, Anda dapat menggunakan animateValueAsState untuk menganimasikan jenis data kustom seperti ini:

data class MySize(val width: Dp, val height: Dp)

@Composable
fun MyAnimation(targetSize: MySize) {
    val animSize: MySize by animateValueAsState<MySize, AnimationVector2D>(
        targetSize,
        TwoWayConverter(
            convertToVector = { size: MySize ->
                // Extract a float value from each of the `Dp` fields.
                AnimationVector2D(size.width.value, size.height.value)
            },
            convertFromVector = { vector: AnimationVector2D ->
                MySize(vector.v1.dp, vector.v2.dp)
            }
        )
    )
}

Gestur dan animasi (lanjutan)

Ada beberapa hal yang perlu dipertimbangkan saat mengerjakan peristiwa sentuh dan animasi, dibandingkan saat kita menangani animasi saja. Terlebih dahulu, kita mungkin perlu menghentikan animasi yang sedang berlangsung saat peristiwa sentuh dimulai karena interaksi pengguna harus diutamakan.

Pada contoh di bawah, kita menggunakan Animatable untuk mewakili posisi offset komponen lingkaran. Peristiwa sentuh diproses dengan pengubah pointerInput. Saat mendeteksi peristiwa ketuk baru, kita memanggil animateTo untuk menganimasi nilai offset ke posisi ketuk. Peristiwa ketuk juga dapat terjadi selama animasi, dan dalam hal ini, animateTo mengganggu animasi yang sedang berlangsung dan memulai animasi ke posisi target baru sambil mempertahankan kecepatan animasi yang terganggu.

@Composable
fun Gesture() {
    val offset = remember { Animatable(Offset(0f, 0f), Offset.VectorConverter) }
    Box(
        modifier = Modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                coroutineScope {
                    while (true) {
                        // Detect a tap event and obtain its position.
                        val position = awaitPointerEventScope {
                            awaitFirstDown().position
                        }
                        launch {
                            // Animate to the tap position.
                            offset.animateTo(position)
                        }
                    }
                }
            }
    ) {
        Circle(modifier = Modifier.offset { offset.value.toIntOffset() })
    }
}

private fun Offset.toIntOffset() = IntOffset(x.roundToInt(), y.roundToInt())

Pola lain yang sering terjadi adalah kita perlu menyinkronkan nilai animasi dengan nilai yang berasal dari peristiwa sentuh, seperti tarik. Pada contoh di bawah, kita melihat "geser untuk menutup" diterapkan sebagai Modifier (bukan menggunakan composable SwipeToDismiss). Offset horizontal elemen direpresentasikan sebagai Animatable. API ini memiliki karakteristik yang berguna dalam animasi gestur. Nilainya dapat diubah oleh peristiwa sentuh serta animasi. Saat menerima peristiwa sentuhan, kita menghentikan Animatable dengan metode stop, sehingga setiap animasi yang sedang berjalan terhenti.

Selama peristiwa tarik, kita menggunakan snapTo untuk memperbarui nilai Animatable dengan nilai yang dihitung dari peristiwa sentuh. Untuk fling, Compose menyediakan VelocityTracker untuk merekam peristiwa tarik dan menghitung kecepatan. Kecepatan dapat diumpankan langsung ke animateDecay untuk animasi fling. Saat ingin menggeser nilai offset kembali ke posisi semula, kita menentukan nilai offset target 0f dengan metode animateTo.

fun Modifier.swipeToDismiss(
    onDismissed: () -> Unit
): Modifier = composed {
    val offsetX = remember { Animatable(0f) }
    pointerInput(Unit) {
        // Used to calculate fling decay.
        val decay = splineBasedDecay<Float>(this)
        // Use suspend functions for touch events and the Animatable.
        coroutineScope {
            while (true) {
                // Detect a touch down event.
                val pointerId = awaitPointerEventScope { awaitFirstDown().id }
                val velocityTracker = VelocityTracker()
                // Stop any ongoing animation.
                offsetX.stop()
                awaitPointerEventScope {
                    horizontalDrag(pointerId) { change ->
                        // Update the animation value with touch events.
                        launch {
                            offsetX.snapTo(
                                offsetX.value + change.positionChange().x
                            )
                        }
                        velocityTracker.addPosition(
                            change.uptimeMillis,
                            change.position
                        )
                    }
                }
                // No longer receiving touch events. Prepare the animation.
                val velocity = velocityTracker.calculateVelocity().x
                val targetOffsetX = decay.calculateTargetValue(
                    offsetX.value,
                    velocity
                )
                // The animation stops when it reaches the bounds.
                offsetX.updateBounds(
                    lowerBound = -size.width.toFloat(),
                    upperBound = size.width.toFloat()
                )
                launch {
                    if (targetOffsetX.absoluteValue <= size.width) {
                        // Not enough velocity; Slide back.
                        offsetX.animateTo(
                            targetValue = 0f,
                            initialVelocity = velocity
                        )
                    } else {
                        // The element was swiped away.
                        offsetX.animateDecay(velocity, decay)
                        onDismissed()
                    }
                }
            }
        }
    }
        .offset { IntOffset(offsetX.value.roundToInt(), 0) }
}

Pengujian

Compose menawarkan ComposeTestRule yang memungkinkan Anda menulis pengujian untuk animasi secara deterministik dengan kontrol penuh selama jam pengujian. Ini memungkinkan Anda memverifikasi nilai animasi menengah. Selain itu, pengujian dapat berjalan lebih cepat dibandingkan durasi animasi yang sebenarnya.

ComposeTestRule menampilkan jam pengujian sebagai mainClock. Anda dapat menyetel properti autoAdvance ke false untuk mengontrol jam di kode pengujian. Setelah memulai animasi yang ingin Anda uji, jam dapat digerakkan dengan advanceTimeBy.

Satu hal yang perlu diperhatikan di sini adalah advanceTimeBy tidak menggerakkan jam persis dengan durasi yang ditentukan. Sebaliknya, ini membulatkannya ke durasi terdekat yang merupakan pengali dari durasi frame.

@get:Rule
val rule = createComposeRule()

@Test
fun testAnimationWithClock() {
    // Pause animations
    rule.mainClock.autoAdvance = false
    var enabled by mutableStateOf(false)
    rule.setContent {
        val color by animateColorAsState(
            targetValue = if (enabled) Color.Red else Color.Green,
            animationSpec = tween(durationMillis = 250)
        )
        Box(Modifier.size(64.dp).background(color))
    }

    // Initiate the animation.
    enabled = true

    // Let the animation proceed.
    rule.mainClock.advanceTimeBy(50L)

    // Compare the result with the image showing the expected result.
    // `assertAgainGolden` needs to be implemented in your code.
    rule.onRoot().captureToImage().assertAgainstGolden()
}

Pelajari lebih lanjut

Untuk mempelajari animasi di Jetpack Compose lebih lanjut, lihat referensi tambahan berikut:

Codelab

Video