Transisi elemen bersama adalah cara yang lancar untuk bertransisi antara composable yang memiliki konten yang konsisten di antara keduanya. Transisi ini sering digunakan untuk navigasi, sehingga Anda dapat menghubungkan layar yang berbeda secara visual saat pengguna melakukan navigasi di antara layar tersebut.
Misalnya, dalam video berikut, Anda dapat melihat gambar dan judul snack yang dibagikan dari halaman listing ke halaman detail.
Di Compose, ada beberapa API tingkat tinggi yang membantu Anda membuat elemen bersama:
SharedTransitionLayout: Tata letak terluar yang diperlukan untuk menerapkan transisi elemen bersama. Tata letak ini menyediakanSharedTransitionScope. Composable harus berada dalamSharedTransitionScopeuntuk menggunakan pengubah elemen bersama.Modifier.sharedElement(): Pengubah yang menandaiSharedTransitionScopecomposable yang harus cocok dengan composable lain.Modifier.sharedBounds(): Pengubah yang menandaiSharedTransitionScopebahwa batas composable ini harus digunakan sebagai batas penampung untuk tempat transisi terjadi. Berbeda dengansharedElement(),sharedBounds()didesain untuk konten yang secara visual berbeda.
Konsep penting saat membuat elemen bersama di Compose adalah cara elemen tersebut berfungsi dengan overlay dan kliping. Lihat bagian kliping dan overlay untuk mempelajari topik penting ini lebih lanjut.
Penggunaan dasar
Transisi berikut akan dibuat di bagian ini, bertransisi dari item "daftar" yang lebih kecil ke item detail yang lebih besar:
Cara terbaik untuk menggunakan Modifier.sharedElement() adalah bersama dengan
AnimatedContent, AnimatedVisibility, atau NavHost, karena cara ini mengelola
transisi antara composable secara otomatis untuk Anda.
Titik awalnya adalah AnimatedContent dasar yang ada dan memiliki composable MainContent, dan DetailsContent sebelum menambahkan elemen bersama:
AnimatedContent tanpa transisi elemen bersama.Untuk membuat elemen bersama beranimasi di antara dua tata letak, sertakan composable
AnimatedContentdenganSharedTransitionLayout. Cakupan dariSharedTransitionLayoutdanAnimatedContentditeruskan keMainContentdanDetailsContent: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 ) } } }
Tambahkan
Modifier.sharedElement()ke rantai pengubah composable Anda pada dua composable yang cocok. Buat objekSharedContentStatedan ingat denganrememberSharedContentState(). ObjekSharedContentStatemenyimpan kunci unik yang menentukan elemen yang dibagikan. Berikan kunci unik untuk mengidentifikasi konten, dan gunakanrememberSharedContentState()agar item diingat.AnimatedContentScopediteruskan 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 kecocokan elemen bersama telah terjadi, ekstrak rememberSharedContentState() ke dalam variabel, dan kueri isMatchFound.
Hal ini akan menghasilkan animasi otomatis berikut:
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 dengan cara berikut:
sharedBounds()adalah untuk konten yang secara visual berbeda tetapi harus berbagi area yang sama di antara status, sedangkansharedElement()mengharapkan kontennya sama.- Dengan
sharedBounds(), konten yang masuk dan keluar dari layar akan terlihat selama transisi antara dua status, sedangkan dengansharedElement()hanya konten target yang dirender dalam batas transformasi.Modifier.sharedBounds()memiliki parameterenterdanexituntuk menentukan cara transisi konten, mirip dengan cara kerjaAnimatedContent. - Kasus penggunaan paling umum untuk
sharedBounds()adalah pola transformasi penampung, sedangkan untuksharedElement(), contoh kasus penggunaannya adalah transisi hero. - Saat menggunakan composable
Text,sharedBounds()lebih disukai 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 berbagi batas keduanya dan melakukan animasi transisi, sehingga keduanya dapat bertambah besar:
@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() ) // ... ) { // ... } } }
Memahami cakupan
Untuk menggunakan Modifier.sharedElement(), composable harus berada dalam SharedTransitionScope. Composable SharedTransitionLayout menyediakan SharedTransitionScope. Pastikan untuk menempatkannya di titik tingkat atas yang sama dalam hierarki UI yang berisi elemen yang ingin Anda bagikan.
Secara umum, composable juga harus ditempatkan di dalam AnimatedVisibilityScope. Hal ini biasanya disediakan dengan menggunakan AnimatedContent
untuk beralih antar-composable atau saat menggunakan AnimatedVisibility secara langsung, atau dengan
fungsi composable NavHost, kecuali jika Anda mengelola visibilitas
secara manual. Untuk menggunakan beberapa cakupan, simpan cakupan yang diperlukan di
CompositionLocal, gunakan penerima konteks di Kotlin, atau teruskan
cakupan sebagai parameter ke fungsi Anda.
Gunakan CompositionLocals dalam skenario saat Anda memiliki beberapa cakupan untuk dilacak, atau hierarki yang bertingkat. 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, cakupannya 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, 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 malas ini, setiap elemen dienkapsulasi dalam AnimatedVisibility. Saat item diklik, konten akan memiliki efek visual yang ditarik keluar dari UI ke dalam 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 } ) }
AnimatedVisibility.Pengurutan pengubah
Dengan Modifier.sharedElement() dan Modifier.sharedBounds(), urutan rantai pengubah Anda penting,
seperti halnya dengan 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 cocok karena perlu mengubah ukuran 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 secara bertahap mengubah turunan dari ukuran awal ke ukuran target.
Pengecualian untuk hal ini adalah jika Anda menggunakan resizeMode = ScaleToBounds() untuk animasi, atau Modifier.skipToLookaheadSize() pada composable. Dalam hal ini, Compose menata letak turunan menggunakan batasan target, dan menggunakan faktor skala untuk melakukan 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 error untuk dicocokkan. Setiap kunci harus unik agar kecocokan dapat terjadi. Misalnya, di Jetsnack, kita memiliki elemen bersama berikut:
Anda dapat membuat enum untuk mewakili jenis elemen bersama. Dalam contoh ini, seluruh kartu snack 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 mungkin tidak menggunakan AnimatedVisibility atau AnimatedContent, Anda dapat mengelola visibilitas elemen bersama sendiri. Gunakan Modifier.sharedElementWithCallerManagedVisibility() dan berikan kondisi 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 antara View dan Compose yang didukung. Hal ini mencakup composable apa pun yang mengenkapsulasi
AndroidView, sepertiDialogatauModalBottomSheet. - Tidak ada dukungan animasi otomatis untuk hal berikut:
- Composable Gambar Bersama:
ContentScaletidak dianimasikan secara default. Composable ini akan di-snap keContentScaleakhir yang ditetapkan.
- Kliping bentuk - Tidak ada dukungan bawaan untuk animasi otomatis antar-bentuk - misalnya, animasi dari persegi ke lingkaran saat item bertransisi.
- Untuk kasus yang tidak didukung, gunakan
Modifier.sharedBounds()dan bukansharedElement(), lalu tambahkanModifier.animateEnterExit()ke item.
- Composable Gambar Bersama: