Transisi elemen bersama di Compose

Transisi elemen bersama adalah cara yang lancar untuk melakukan transisi antar-composable yang memiliki konten yang konsisten di antara keduanya. Transisi ini sering digunakan untuk navigasi, sehingga Anda dapat menghubungkan berbagai layar secara visual saat pengguna berpindah di antaranya.

Misalnya, dalam video berikut, Anda dapat melihat gambar dan judul snack dibagikan dari halaman listingan ke halaman detail.

Gambar 1. Demo elemen bersama Jetsnack.

Di Compose, ada beberapa API tingkat tinggi yang membantu Anda membuat elemen bersama:

  • SharedTransitionLayout: Tata letak terluar yang diperlukan untuk menerapkan transisi elemen bersama. Alat ini menyediakan SharedTransitionScope. Composable harus berada dalam SharedTransitionScope untuk menggunakan pengubah elemen bersama.
  • Modifier.sharedElement(): Pengubah yang menandai SharedTransitionScope composable yang harus dicocokkan dengan composable lain.
  • Modifier.sharedBounds(): Pengubah yang menandai ke SharedTransitionScope bahwa batas composable ini harus digunakan sebagai batas penampung tempat transisi harus dilakukan. Berbeda dengan sharedElement(), sharedBounds() dirancang untuk konten yang berbeda secara visual.

Konsep penting saat membuat elemen bersama di Compose adalah cara kerjanya dengan overlay dan pemangkasan. Lihat bagian kliping dan overlay untuk mempelajari lebih lanjut topik penting ini.

Penggunaan dasar

Transisi berikut akan dibuat di bagian ini, bertransisi dari item "daftar" yang lebih kecil, ke item mendetail yang lebih besar:

Gambar 2. Contoh dasar transisi elemen bersama antara dua composable.

Cara terbaik untuk menggunakan Modifier.sharedElement() adalah bersama dengan AnimatedContent, AnimatedVisibility, atau NavHost, karena cara ini akan mengelola transisi antar-composable secara otomatis untuk Anda.

Titik awalnya adalah AnimatedContent dasar yang sudah ada dan memiliki composable MainContent, dan DetailsContent sebelum menambahkan elemen bersama:

Gambar 3. Memulai AnimatedContent tanpa transisi elemen bersama.

  1. Untuk membuat elemen bersama beranimasi di antara dua tata letak, kelilingi composable AnimatedContent dengan SharedTransitionLayout. Cakupan dari SharedTransitionLayout dan AnimatedContent diteruskan ke MainContent dan DetailsContent:

    var showDetails by remember {
        mutableStateOf(false)
    }
    SharedTransitionLayout {
        AnimatedContent(
            showDetails,
            label = "basic_transition"
        ) { targetState ->
            if (!targetState) {
                MainContent(
                    onShowDetails = {
                        showDetails = true
                    },
                    animatedVisibilityScope = this@AnimatedContent,
                    sharedTransitionScope = this@SharedTransitionLayout
                )
            } else {
                DetailsContent(
                    onBack = {
                        showDetails = false
                    },
                    animatedVisibilityScope = this@AnimatedContent,
                    sharedTransitionScope = this@SharedTransitionLayout
                )
            }
        }
    }

  2. Tambahkan Modifier.sharedElement() ke rantai pengubah composable Anda di dua composable yang cocok. Buat objek SharedContentState dan ingat dengan rememberSharedContentState(). Objek SharedContentState menyimpan kunci unik yang menentukan elemen yang dibagikan. Berikan kunci unik untuk mengidentifikasi konten, dan gunakan rememberSharedContentState() untuk item yang akan diingat. AnimatedContentScope diteruskan ke pengubah, yang digunakan untuk mengoordinasikan animasi.

    @Composable
    private fun MainContent(
        onShowDetails: () -> Unit,
        modifier: Modifier = Modifier,
        sharedTransitionScope: SharedTransitionScope,
        animatedVisibilityScope: AnimatedVisibilityScope
    ) {
        Row(
            // ...
        ) {
            with(sharedTransitionScope) {
                Image(
                    painter = painterResource(id = R.drawable.cupcake),
                    contentDescription = "Cupcake",
                    modifier = Modifier
                        .sharedElement(
                            rememberSharedContentState(key = "image"),
                            animatedVisibilityScope = animatedVisibilityScope
                        )
                        .size(100.dp)
                        .clip(CircleShape),
                    contentScale = ContentScale.Crop
                )
                // ...
            }
        }
    }
    
    @Composable
    private fun DetailsContent(
        modifier: Modifier = Modifier,
        onBack: () -> Unit,
        sharedTransitionScope: SharedTransitionScope,
        animatedVisibilityScope: AnimatedVisibilityScope
    ) {
        Column(
            // ...
        ) {
            with(sharedTransitionScope) {
                Image(
                    painter = painterResource(id = R.drawable.cupcake),
                    contentDescription = "Cupcake",
                    modifier = Modifier
                        .sharedElement(
                            rememberSharedContentState(key = "image"),
                            animatedVisibilityScope = animatedVisibilityScope
                        )
                        .size(200.dp)
                        .clip(CircleShape),
                    contentScale = ContentScale.Crop
                )
                // ...
            }
        }
    }

Untuk mendapatkan informasi tentang apakah pencocokan elemen bersama telah terjadi, ekstrak rememberSharedContentState() ke dalam variabel, dan buat kueri isMatchFound.

Tindakan ini akan menghasilkan animasi otomatis berikut:

Gambar 4. Contoh dasar transisi elemen bersama antara dua composable.

Anda mungkin melihat bahwa warna latar belakang dan ukuran seluruh penampung masih menggunakan setelan AnimatedContent default.

Batas bersama versus elemen bersama

Modifier.sharedBounds() mirip dengan Modifier.sharedElement(). Namun, pengubahnya berbeda dalam hal berikut:

  • sharedBounds() ditujukan untuk konten yang berbeda secara visual, tetapi harus berbagi area yang sama di antara status, sedangkan sharedElement() mengharapkan kontennya sama.
  • Dengan sharedBounds(), konten yang masuk dan keluar dari layar akan terlihat selama transisi antara dua status, sedangkan dengan sharedElement(), hanya konten target yang dirender dalam batas transformasi. Modifier.sharedBounds() memiliki parameter enter dan exit untuk menentukan cara konten akan bertransisi, mirip dengan cara kerja AnimatedContent.
  • Kasus penggunaan paling umum untuk sharedBounds() adalah pola transformasi penampung, sedangkan untuk sharedElement(), contoh kasus penggunaannya adalah transisi hero.
  • Saat menggunakan composable Text, sharedBounds() lebih disarankan untuk mendukung perubahan font seperti transisi antara miring dan tebal atau perubahan warna.

Dari contoh sebelumnya, menambahkan Modifier.sharedBounds() ke Row dan Column dalam dua skenario yang berbeda akan memungkinkan kita membagikan batas keduanya dan melakukan animasi transisi, sehingga keduanya dapat berkembang di antara satu sama lain:

@Composable
private fun MainContent(
    onShowDetails: () -> Unit,
    modifier: Modifier = Modifier,
    sharedTransitionScope: SharedTransitionScope,
    animatedVisibilityScope: AnimatedVisibilityScope
) {
    with(sharedTransitionScope) {
        Row(
            modifier = Modifier
                .padding(8.dp)
                .sharedBounds(
                    rememberSharedContentState(key = "bounds"),
                    animatedVisibilityScope = animatedVisibilityScope,
                    enter = fadeIn(),
                    exit = fadeOut(),
                    resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds()
                )
                // ...
        ) {
            // ...
        }
    }
}

@Composable
private fun DetailsContent(
    modifier: Modifier = Modifier,
    onBack: () -> Unit,
    sharedTransitionScope: SharedTransitionScope,
    animatedVisibilityScope: AnimatedVisibilityScope
) {
    with(sharedTransitionScope) {
        Column(
            modifier = Modifier
                .padding(top = 200.dp, start = 16.dp, end = 16.dp)
                .sharedBounds(
                    rememberSharedContentState(key = "bounds"),
                    animatedVisibilityScope = animatedVisibilityScope,
                    enter = fadeIn(),
                    exit = fadeOut(),
                    resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds()
                )
                // ...

        ) {
            // ...
        }
    }
}

Gambar 5. Batas bersama antara dua composable.

Memahami cakupan

Untuk menggunakan Modifier.sharedElement(), composable harus berada dalam SharedTransitionScope. Composable SharedTransitionLayout menyediakan SharedTransitionScope. Pastikan untuk menempatkannya di titik tingkat teratas yang sama dalam hierarki UI yang berisi elemen yang ingin Anda bagikan.

Umumnya, composable juga harus ditempatkan di dalam AnimatedVisibilityScope. Hal ini biasanya disediakan dengan menggunakan AnimatedContent untuk beralih di antara composable atau saat menggunakan AnimatedVisibility secara langsung, atau oleh fungsi composable NavHost, kecuali jika Anda mengelola visibilitas secara manual. Untuk menggunakan beberapa cakupan, simpan cakupan yang diperlukan dalam CompositionLocal, gunakan penerima konteks di Kotlin, atau teruskan cakupan sebagai parameter ke fungsi Anda.

Gunakan CompositionLocals dalam skenario saat Anda memiliki beberapa cakupan yang harus dilacak, atau hierarki bertingkat yang dalam. CompositionLocal memungkinkan Anda memilih cakupan yang tepat untuk disimpan dan digunakan. Di sisi lain, saat Anda menggunakan penerima konteks, tata letak lain dalam hierarki Anda mungkin secara tidak sengaja mengganti cakupan yang disediakan. Misalnya, jika Anda memiliki beberapa AnimatedContent bertingkat, cakupan dapat diganti.

val LocalNavAnimatedVisibilityScope = compositionLocalOf<AnimatedVisibilityScope?> { null }
val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope?> { null }

@Composable
private fun SharedElementScope_CompositionLocal() {
    // An example of how to use composition locals to pass around the shared transition scope, far down your UI tree.
    // ...
    SharedTransitionLayout {
        CompositionLocalProvider(
            LocalSharedTransitionScope provides this
        ) {
            // This could also be your top-level NavHost as this provides an AnimatedContentScope
            AnimatedContent(state, label = "Top level AnimatedContent") { targetState ->
                CompositionLocalProvider(LocalNavAnimatedVisibilityScope provides this) {
                    // Now we can access the scopes in any nested composables as follows:
                    val sharedTransitionScope = LocalSharedTransitionScope.current
                        ?: throw IllegalStateException("No SharedElementScope found")
                    val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current
                        ?: throw IllegalStateException("No AnimatedVisibility found")
                }
                // ...
            }
        }
    }
}

Atau, jika hierarki Anda tidak bertingkat dalam, Anda dapat meneruskan cakupan sebagai parameter:

@Composable
fun MainContent(
    animatedVisibilityScope: AnimatedVisibilityScope,
    sharedTransitionScope: SharedTransitionScope
) {
}

@Composable
fun Details(
    animatedVisibilityScope: AnimatedVisibilityScope,
    sharedTransitionScope: SharedTransitionScope
) {
}

Elemen bersama dengan AnimatedVisibility

Contoh sebelumnya menunjukkan cara menggunakan elemen bersama dengan AnimatedContent, tetapi elemen bersama juga berfungsi dengan AnimatedVisibility.

Misalnya, dalam contoh petak lambat ini, setiap elemen digabungkan dalam AnimatedVisibility. Saat item diklik, konten akan memiliki efek visual ditarik keluar dari UI ke komponen seperti dialog.

var selectedSnack by remember { mutableStateOf<Snack?>(null) }

SharedTransitionLayout(modifier = Modifier.fillMaxSize()) {
    LazyColumn(
        // ...
    ) {
        items(listSnacks) { snack ->
            AnimatedVisibility(
                visible = snack != selectedSnack,
                enter = fadeIn() + scaleIn(),
                exit = fadeOut() + scaleOut(),
                modifier = Modifier.animateItem()
            ) {
                Box(
                    modifier = Modifier
                        .sharedBounds(
                            sharedContentState = rememberSharedContentState(key = "${snack.name}-bounds"),
                            // Using the scope provided by AnimatedVisibility
                            animatedVisibilityScope = this,
                            clipInOverlayDuringTransition = OverlayClip(shapeForSharedElement)
                        )
                        .background(Color.White, shapeForSharedElement)
                        .clip(shapeForSharedElement)
                ) {
                    SnackContents(
                        snack = snack,
                        modifier = Modifier.sharedElement(
                            sharedContentState = rememberSharedContentState(key = snack.name),
                            animatedVisibilityScope = this@AnimatedVisibility
                        ),
                        onClick = {
                            selectedSnack = snack
                        }
                    )
                }
            }
        }
    }
    // Contains matching AnimatedContent with sharedBounds modifiers.
    SnackEditDetails(
        snack = selectedSnack,
        onConfirmClick = {
            selectedSnack = null
        }
    )
}

Gambar 6. Elemen bersama dengan AnimatedVisibility.

Urutan pengubah

Dengan Modifier.sharedElement() dan Modifier.sharedBounds(), urutan rantai pengubah Anda penting, seperti halnya Compose lainnya. Penempatan pengubah yang memengaruhi ukuran yang salah dapat menyebabkan lompatan visual yang tidak terduga selama pencocokan elemen bersama.

Misalnya, jika Anda menempatkan pengubah padding di posisi yang berbeda pada dua elemen bersama, akan ada perbedaan visual dalam animasi.

var selectFirst by remember { mutableStateOf(true) }
val key = remember { Any() }
SharedTransitionLayout(
    Modifier
        .fillMaxSize()
        .padding(10.dp)
        .clickable {
            selectFirst = !selectFirst
        }
) {
    AnimatedContent(targetState = selectFirst, label = "AnimatedContent") { targetState ->
        if (targetState) {
            Box(
                Modifier
                    .padding(12.dp)
                    .sharedBounds(
                        rememberSharedContentState(key = key),
                        animatedVisibilityScope = this@AnimatedContent
                    )
                    .border(2.dp, Color.Red)
            ) {
                Text(
                    "Hello",
                    fontSize = 20.sp
                )
            }
        } else {
            Box(
                Modifier
                    .offset(180.dp, 180.dp)
                    .sharedBounds(
                        rememberSharedContentState(
                            key = key,
                        ),
                        animatedVisibilityScope = this@AnimatedContent
                    )
                    .border(2.dp, Color.Red)
                    // This padding is placed after sharedBounds, but it doesn't match the
                    // other shared elements modifier order, resulting in visual jumps
                    .padding(12.dp)

            ) {
                Text(
                    "Hello",
                    fontSize = 36.sp
                )
            }
        }
    }
}

Batas yang cocok

Batas yang tidak cocok: Perhatikan bagaimana animasi elemen bersama tampak sedikit tidak pas karena perlu diubah ukurannya ke batas yang salah

Pengubah yang digunakan sebelum pengubah elemen bersama memberikan batasan pada pengubah elemen bersama, yang kemudian digunakan untuk mendapatkan batas awal dan target, dan selanjutnya animasi batas.

Pengubah yang digunakan setelah pengubah elemen bersama menggunakan batasan dari sebelumnya untuk mengukur dan menghitung ukuran target turunan. Pengubah elemen bersama membuat serangkaian batasan animasi untuk mengubah turunan secara bertahap dari ukuran awal ke ukuran target.

Pengecualiannya adalah jika Anda menggunakan resizeMode = ScaleToBounds() untuk animasi, atau Modifier.skipToLookaheadSize() pada composable. Dalam hal ini, Compose akan menata letak turunan menggunakan batasan target, dan menggunakan faktor skala untuk menjalankan animasi, bukan mengubah ukuran tata letak itu sendiri.

Kunci unik

Saat menggunakan elemen bersama yang kompleks, sebaiknya buat kunci yang bukan string, karena string dapat rentan terhadap error saat dicocokkan. Setiap kunci harus unik agar pencocokan dapat terjadi. Misalnya, di Jetsnack, kita memiliki elemen bersama berikut:

Gambar 7. Gambar yang menampilkan Jetsnack dengan anotasi untuk setiap bagian UI.

Anda dapat membuat enum untuk merepresentasikan jenis elemen bersama. Dalam contoh ini, seluruh kartu camilan juga dapat muncul dari beberapa tempat berbeda di layar utama, misalnya di bagian "Populer" dan "Direkomendasikan". Anda dapat membuat kunci yang memiliki snackId, origin ("Populer" / "Direkomendasikan"), dan type elemen bersama yang akan dibagikan:

data class SnackSharedElementKey(
    val snackId: Long,
    val origin: String,
    val type: SnackSharedElementType
)

enum class SnackSharedElementType {
    Bounds,
    Image,
    Title,
    Tagline,
    Background
}

@Composable
fun SharedElementUniqueKey() {
    // ...
            Box(
                modifier = Modifier
                    .sharedElement(
                        rememberSharedContentState(
                            key = SnackSharedElementKey(
                                snackId = 1,
                                origin = "latest",
                                type = SnackSharedElementType.Image
                            )
                        ),
                        animatedVisibilityScope = this@AnimatedVisibility
                    )
            )
            // ...
}

Class data direkomendasikan untuk kunci karena mengimplementasikan hashCode() dan equals().

Mengelola visibilitas elemen bersama secara manual

Jika Anda tidak menggunakan AnimatedVisibility atau AnimatedContent, Anda dapat mengelola visibilitas elemen bersama sendiri. Gunakan Modifier.sharedElementWithCallerManagedVisibility() dan berikan kondisional Anda sendiri yang menentukan kapan item harus terlihat atau tidak:

var selectFirst by remember { mutableStateOf(true) }
val key = remember { Any() }
SharedTransitionLayout(
    Modifier
        .fillMaxSize()
        .padding(10.dp)
        .clickable {
            selectFirst = !selectFirst
        }
) {
    Box(
        Modifier
            .sharedElementWithCallerManagedVisibility(
                rememberSharedContentState(key = key),
                !selectFirst
            )
            .background(Color.Red)
            .size(100.dp)
    ) {
        Text(if (!selectFirst) "false" else "true", color = Color.White)
    }
    Box(
        Modifier
            .offset(180.dp, 180.dp)
            .sharedElementWithCallerManagedVisibility(
                rememberSharedContentState(
                    key = key,
                ),
                selectFirst
            )
            .alpha(0.5f)
            .background(Color.Blue)
            .size(180.dp)
    ) {
        Text(if (selectFirst) "false" else "true", color = Color.White)
    }
}

Batasan saat ini

API ini memiliki beberapa batasan. Terutama:

  • Tidak ada interoperabilitas yang didukung antara View dan Compose. Hal ini mencakup composable apa pun yang menggabungkan AndroidView, seperti Dialog atau ModalBottomSheet.
  • Tidak ada dukungan animasi otomatis untuk hal berikut:
    • Composable Gambar Bersama:
      • ContentScale tidak dianimasikan secara default. Tindakan ini akan menyesuaikan dengan akhir yang ditetapkan ContentScale.
    • Pemangkasan bentuk - Tidak ada dukungan bawaan untuk animasi otomatis antar-bentuk, misalnya, menganimasikan dari persegi ke lingkaran saat item bertransisi.
    • Untuk kasus yang tidak didukung, gunakan Modifier.sharedBounds(), bukan sharedElement(), dan tambahkan Modifier.animateEnterExit() ke item.