Transisi elemen bersama adalah cara yang lancar untuk melakukan transisi antar-composable yang memiliki konten yang konsisten di antara keduanya. API ini sering digunakan untuk navigasi, sehingga Anda dapat menghubungkan berbagai layar secara visual saat pengguna menavigasi di antara layar tersebut.
Misalnya, dalam video berikut, Anda dapat melihat gambar dan judul snack dibagikan dari halaman listingan, ke halaman detail.
Di Compose, ada beberapa API tingkat tinggi yang membantu Anda membuat elemen bersama:
SharedTransitionLayout
: Tata letak terluar yang diperlukan untuk mengimplementasikan transisi elemen bersama. Class ini menyediakanSharedTransitionScope
. Composable harus berada dalamSharedTransitionScope
agar dapat menggunakan pengubah elemen bersama.Modifier.sharedElement()
: Pengubah yang menandaiSharedTransitionScope
composable yang harus dicocokkan dengan composable lainnya.Modifier.sharedBounds()
: Pengubah yang menandai keSharedTransitionScope
bahwa batas composable ini harus digunakan sebagai batas penampung tempat transisi akan dilakukan. Berbeda dengansharedElement()
,sharedBounds()
didesain untuk konten yang berbeda secara visual.
Konsep penting saat membuat elemen bersama di Compose adalah cara kerjanya dengan overlay dan penyesuaian nilai. Lihat bagian pengeklipan dan overlay untuk mempelajari lebih lanjut tentang topik penting ini.
Penggunaan Dasar
Transisi berikut akan dibuat di bagian ini, dengan bertransisi dari item "daftar" yang lebih kecil ke item dengan detail yang lebih besar:
![](https://developer.android.google.cn/static/develop/ui/compose/images/animations/shared-element/basic_shared_element_jetsnack.gif?hl=id)
Cara terbaik untuk menggunakan Modifier.sharedElement()
adalah bersama dengan
AnimatedContent
, AnimatedVisibility
, atau NavHost
karena cara ini mengelola
transisi antar-composable secara otomatis untuk Anda.
Titik awalnya adalah AnimatedContent
dasar yang sudah ada yang memiliki
composable MainContent
, dan DetailsContent
sebelum menambahkan elemen bersama:
![](https://developer.android.google.cn/static/develop/ui/compose/images/animations/shared-element/basic_no_animation_jetsnack.gif?hl=id)
AnimatedContent
tanpa transisi elemen bersama.Agar elemen bersama bergerak di antara dua tata letak, kelilingi composable
AnimatedContent
denganSharedTransitionLayout
. Cakupan dariSharedTransitionLayout
danAnimatedContent
diteruskan keMainContent
danDetailsContent
: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 pada dua composable yang cocok. Buat objekSharedContentState
dan ingat denganrememberSharedContentState()
. ObjekSharedContentState
menyimpan kunci unik yang menentukan elemen yang dibagikan. Berikan kunci unik untuk mengidentifikasi konten, dan gunakanrememberSharedContentState()
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 telah terjadi kecocokan elemen bersama, ekstrak
rememberSharedContentState()
ke dalam variabel, dan buat kueri isMatchFound
.
Yang menghasilkan animasi otomatis berikut:
![](https://developer.android.google.cn/static/develop/ui/compose/images/animations/shared-element/basic_shared_element_jetsnack.gif?hl=id)
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 secara visual berbeda, tetapi harus memiliki area yang sama antar-status, sedangkansharedElement()
mengharapkan konten yang sama.- Dengan
sharedBounds()
, konten yang masuk dan keluar layar akan terlihat selama transisi antara kedua status, sedangkan dengansharedElement()
hanya konten target yang akan dirender dalam batas transformasi.Modifier.sharedBounds()
memiliki parameterenter
danexit
untuk menentukan cara konten harus ditransisikan, mirip dengan cara kerjaAnimatedContent
. - Kasus penggunaan yang paling umum untuk
sharedBounds()
adalah pola transformasi container, sedangkan untuksharedElement()
contoh kasus penggunaannya adalah transisi utama. - Saat menggunakan composable
Text
,sharedBounds()
lebih disarankan untuk mendukung perubahan font seperti transisi antara perubahan cetak miring dan tebal atau warna.
Dari contoh sebelumnya, menambahkan Modifier.sharedBounds()
ke Row
dan
Column
dalam dua skenario yang berbeda akan memungkinkan kita untuk berbagi batas keduanya dan menjalankan animasi transisi, yang memungkinkannya untuk tumbuh
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() ) // ... ) { // ... } } }
Memahami Ruang Lingkup
Untuk menggunakan Modifier.sharedElement()
, composable harus berada di
SharedTransitionScope
. Composable SharedTransitionLayout
menyediakan
SharedTransitionScope
. Pastikan untuk menempatkan 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 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 ketika Anda memiliki beberapa cakupan yang harus dilacak, atau hierarki yang sangat 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 tidak sengaja mengganti cakupan yang diberikan.
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 tidak disarangkan secara mendalam, 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 memiliki
efek visual seperti ditarik 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( state = 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 menjadi penting,
seperti halnya dengan Compose. Penempatan pengubah yang memengaruhi ukuran yang salah
dapat menyebabkan lompatan visual yang tidak terduga selama pencocokan elemen bersama.
Misalnya, jika Anda menempatkan pengubah padding pada posisi yang berbeda pada dua elemen bersama, 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 harus diubah ukurannya ke batas yang salah |
---|---|
Pengubah yang digunakan sebelum pengubah elemen bersama memberikan batasan untuk pengubah elemen bersama, yang kemudian digunakan untuk mendapatkan batas awal dan target, kemudian 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.
Pengecualian untuk hal ini adalah jika Anda menggunakan resizeMode = ScaleToBounds()
untuk
animasi, atau Modifier.skipToLookaheadSize()
pada composable. Dalam hal
ini, Compose membuat tata letak turunan menggunakan batasan target, dan menggunakan
faktor skala untuk menjalankan animasi, bukan mengubah ukuran tata letak
itu sendiri.
Kunci unik
Saat menangani elemen bersama yang kompleks, sebaiknya buat kunci yang bukan string, karena string rentan terhadap error. Setiap kunci harus unik agar kecocokan terjadi. Misalnya, di Jetsnack kita memiliki elemen bersama berikut:
![](https://developer.android.google.cn/static/develop/ui/compose/images/animations/shared-element/unique_keys_shared_elements.jpeg?hl=id)
Anda dapat membuat enum untuk mewakili jenis elemen bersama. Dalam contoh ini,
seluruh kartu camilan juga dapat muncul dari beberapa tempat yang berbeda di layar
utama, misalnya di bagian "Populer" dan "Direkomendasikan". Anda dapat membuat
kunci yang memiliki snackId
, origin
("Populer" / "Direkomendasikan"), dan
type
dari 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
isEquals()
.
Mengelola visibilitas elemen bersama secara manual
Jika tidak menggunakan AnimatedVisibility
atau AnimatedContent
,
Anda dapat mengelola sendiri visibilitas elemen bersama. 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 tersebut memiliki beberapa batasan. Terutama:
- Tidak ada interoperabilitas antara View dan Compose yang didukung. Ini mencakup
composable apa pun yang menggabungkan
AndroidView
, sepertiDialog
. - Tidak ada dukungan animasi otomatis untuk hal berikut:
- Composable Gambar Bersama:
- Secara default,
ContentScale
tidak dianimasikan. Kunci ini mengepaskan ke akhir yang ditetapkanContentScale
.
- Secara default,
- Pemangkasan bentuk - Tidak ada dukungan bawaan untuk animasi otomatis antarbentuk - misalnya membuat animasi dari persegi ke lingkaran saat transisi item.
- Untuk kasus yang tidak didukung, gunakan
Modifier.sharedBounds()
, bukansharedElement()
, dan tambahkanModifier.animateEnterExit()
ke item.
- Composable Gambar Bersama: