共用元素轉換功能可在可組合項之間順暢轉換,前提是可組合項之間具有一致的內容。這些標記通常用於導覽用途,讓您能在使用者瀏覽不同畫面時,透過視覺效果連結不同畫面。
舉例來說,在下列影片中,您可以看到從清單頁面和詳細資料頁面分享點心的圖片和標題。
Compose 中提供了幾種高階 API,可協助您建立共用元素:
SharedTransitionLayout
:實作共用元素轉換效果所需的最外層版面配置。並提供SharedTransitionScope
。可組合項必須位於SharedTransitionScope
中,才能使用共用元素修飾符。Modifier.sharedElement()
:這個修飾符會標記SharedTransitionScope
可組合項,其應與其他可組合項配對。Modifier.sharedBounds()
:用於標記此可組合項邊界的SharedTransitionScope
修飾符,用來做為轉場效果的容器邊界。與sharedElement()
相比,sharedBounds()
是專為外觀不同的內容而設計。
在 Compose 中建立共用元素時,一個重要概念就是如何使用重疊和裁剪。如要進一步瞭解這個主題,請參閱「裁剪和疊加層」一節。
基本用法
本節將會建構下列轉換效果,從較小的「清單」項目轉換為較大的詳細項目:
Modifier.sharedElement()
的最佳使用方式是將 AnimatedContent
、AnimatedVisibility
或 NavHost
搭配使用,系統會自動為您管理可組合項之間的轉換。
起點是包含 MainContent
和 DetailsContent
可組合項的現有基本 AnimatedContent
,然後再新增共用元素:
為了在兩個版面配置之間為共用元素建立動畫效果,請在
AnimatedContent
可組合項周圍加上SharedTransitionLayout
。SharedTransitionLayout
和AnimatedContent
中的範圍會傳遞至MainContent
和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 ) } } }
在兩個相符的可組合項中,將
Modifier.sharedElement()
新增至可組合項修飾符鏈結。建立SharedContentState
物件,並使用rememberSharedContentState()
記住該物件。SharedContentState
物件會儲存專屬索引鍵,這個索引鍵會決定共用的元素。請提供用來識別內容的專屬鍵,並將rememberSharedContentState()
用於要記住的項目。AnimatedContentScope
會傳遞至修飾符,用於協調動畫。@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 ) // ... } } }
如要瞭解是否發生共用元素比對結果,請將 rememberSharedContentState()
擷取至變數,並查詢 isMatchFound
。
這會導致以下自動動畫播放:
您可能會發現,整個容器的背景顏色和大小仍然使用預設的 AnimatedContent
設定。
共用邊界與共用元素
Modifier.sharedBounds()
與 Modifier.sharedElement()
類似,然而,修飾符在以下方面有所不同:
sharedBounds()
適用於視覺上的差異,但狀態之間應共用相同區域的內容,而sharedElement()
會預期內容會是相同的內容。- 使用
sharedBounds()
時,在兩個狀態之間的轉換期間,就能看到進入和離開畫面的內容,而如果使用sharedElement()
,則只會在轉換的邊界內轉譯目標內容。Modifier.sharedBounds()
具有enter
和exit
參數,用於指定內容轉換方式,與AnimatedContent
的運作方式類似。 sharedBounds()
最常見的用途是容器轉換模式,而sharedElement()
的範例用途則是主要轉換。- 使用
Text
可組合項時,建議採用sharedBounds()
支援字型變更,例如在斜體和粗體轉換之間轉換,或是變更顏色。
從上一個範例中,在 Row
和 Column
的兩個不同情境中加入 Modifier.sharedBounds()
,可讓我們共用兩者的邊界並執行轉場動畫,讓兩者之間的成長:
@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() ) // ... ) { // ... } } }
瞭解範圍
如要使用 Modifier.sharedElement()
,可組合項必須位於 SharedTransitionScope
中。SharedTransitionLayout
可組合項提供 SharedTransitionScope
。針對您要共用的元素,請務必將其放在 UI 階層中的同一個頂層點。
一般來說,可組合項也應置於 AnimatedVisibilityScope
內。這通常是透過 AnimatedContent
切換可組合項、直接使用 AnimatedVisibility
時提供,或者使用 NavHost
可組合函式提供 (除非您手動管理瀏覽權限)。若要使用多個範圍,請將所需範圍儲存在 CompositionLocal、使用 Kotlin 中的內容接收器,或將範圍做為參數傳遞至函式。
在您有多個範圍追蹤或包含深層巢狀階層的情況下,請使用 CompositionLocals
。CompositionLocal
可讓您選擇要儲存及使用的確切範圍。另一方面,當您使用結構定義接收器時,階層中的其他版面配置可能會意外覆寫提供的範圍。舉例來說,如果有多個巢狀 AnimatedContent
,則可覆寫範圍。
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") } // ... } } } }
或者,如果您的階層並未建立深層巢狀結構,也可以將範圍向下傳遞做為參數:
@Composable fun MainContent( animatedVisibilityScope: AnimatedVisibilityScope, sharedTransitionScope: SharedTransitionScope ) { } @Composable fun Details( animatedVisibilityScope: AnimatedVisibilityScope, sharedTransitionScope: SharedTransitionScope ) { }
具有 AnimatedVisibility
的共用元素
先前的範例說明如何將共用元素與 AnimatedContent
搭配使用,但共用元素也可以搭配 AnimatedVisibility
使用。
例如,在這個延遲格線範例中,每個元素都會納入 AnimatedVisibility
中。當使用者點選項目時,內容會使視覺效果從 UI 提取到類似對話方塊的元件。
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 } ) }
修飾符排序
使用 Modifier.sharedElement()
和 Modifier.sharedBounds()
時,修飾符的順序至關重要,就像 Compose 的其餘部分一樣。對大小影響大小的修飾符位置不正確,可能會導致共用元素比對期間出現非預期的視覺跳動。
舉例來說,如果您在兩個共用元素上的不同位置放置邊框間距修飾符,動畫就會產生視覺差異。
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 ) } } } }
相符的範圍 |
不相符的邊界:請注意,共用元素動畫的顯示效果會有些微淡入,因為此動畫需要調整成正確的邊界 |
---|---|
在共用元素修飾符「之前」使用的修飾符,會對共用元素修飾符設下限制,然後用來導出初始和目標邊界,以及後續的邊界動畫。
在共用元素修飾符「之後」使用的修飾符,會使用之前的限制測量及計算子項的目標大小。共用元素修飾符會建立一系列的動畫限制,以逐步將子項從初始大小轉換為目標大小。
例外狀況:如果您的動畫使用 resizeMode = ScaleToBounds()
,或在可組合項中使用 Modifier.skipToLookaheadSize()
,則屬於例外狀況。在這種情況下,Compose 會根據目標限制安排子項的版面配置,並改用縮放比例係數來執行動畫,而非變更版面配置大小。
不重複的索引鍵
使用複雜的共用元素時,建議建立不是字串的鍵,因為字串很容易相符。每個鍵都不得重複,才能進行比對。舉例來說,在 Jetsnack 中,我們有下列共用元素:
您可以建立列舉來代表共用元素類型。在本範例中,整份點心資訊卡也可能會從主畫面的多個不同位置顯示,例如「熱門」和「推薦」區段。您可以建立具有 snackId
、origin
(「熱門」/「建議」) 的鍵,以及要共用的共用元素的 type
:
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 ) ) // ... }
建議為鍵使用資料類別,因為這類類別實作 hashCode()
和 isEquals()
。
手動管理共用元素的瀏覽權限
如果您可能不使用 AnimatedVisibility
或 AnimatedContent
,您可以自行管理共用元素的瀏覽權限。使用 Modifier.sharedElementWithCallerManagedVisibility()
並自行提供條件,以決定項目的顯示時機:
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) } }
目前限制
這些 API 有幾項限制,最值得注意的是:
- View 和 Compose 之間不支援互通性。這包括任何納入
AndroidView
的可組合項,例如Dialog
。 - 以下程式碼不支援自動動畫:
- 共用 Image 可組合項:
- 根據預設,
ContentScale
不會動畫。這會貼齊設定的結尾ContentScale
。
- 根據預設,
- 形狀裁剪 - 目前不支援在形狀之間自動建立動畫,例如在項目轉換時從正方形動畫到圓形。
- 若是不支援的情況,請使用
Modifier.sharedBounds()
而非sharedElement()
,然後在項目中加入Modifier.animateEnterExit()
。
- 共用 Image 可組合項: