Compose 中的共用元素轉換

共用元素轉換功能可在可組合項之間順暢轉換,前提是可組合項之間具有一致的內容。這些標記通常用於導覽用途,讓您能在使用者瀏覽不同畫面時,透過視覺效果連結不同畫面。

舉例來說,在下列影片中,您可以看到從清單頁面和詳細資料頁面分享點心的圖片和標題。

圖 1.Jetsnack 共用元素示範

Compose 中提供了幾種高階 API,可協助您建立共用元素:

  • SharedTransitionLayout:實作共用元素轉換效果所需的最外層版面配置。並提供 SharedTransitionScope。可組合項必須位於 SharedTransitionScope 中,才能使用共用元素修飾符。
  • Modifier.sharedElement():這個修飾符會標記 SharedTransitionScope 可組合項,其應與其他可組合項配對。
  • Modifier.sharedBounds():用於標記此可組合項邊界的 SharedTransitionScope 修飾符,用來做為轉場效果的容器邊界。與 sharedElement() 相比,sharedBounds() 是專為外觀不同的內容而設計。

在 Compose 中建立共用元素時,一個重要概念就是如何使用重疊和裁剪。如要進一步瞭解這個主題,請參閱「裁剪和疊加層」一節。

基本用法

本節將會建構下列轉換效果,從較小的「清單」項目轉換為較大的詳細項目:

圖 2.兩個可組合項之間的共用元素轉換基本範例。

Modifier.sharedElement() 的最佳使用方式是將 AnimatedContentAnimatedVisibilityNavHost 搭配使用,系統會自動為您管理可組合項之間的轉換。

起點是包含 MainContentDetailsContent 可組合項的現有基本 AnimatedContent,然後再新增共用元素:

圖 3.開始 AnimatedContent,且沒有任何共用元素轉換。

  1. 為了在兩個版面配置之間為共用元素建立動畫效果,請在 AnimatedContent 可組合項周圍加上 SharedTransitionLayoutSharedTransitionLayoutAnimatedContent 中的範圍會傳遞至 MainContentDetailsContent

    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. 在兩個相符的可組合項中,將 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

這會導致以下自動動畫播放:

圖 4.兩個可組合項之間的共用元素轉換基本範例。

您可能會發現,整個容器的背景顏色和大小仍然使用預設的 AnimatedContent 設定。

共用邊界與共用元素

Modifier.sharedBounds()Modifier.sharedElement() 類似,然而,修飾符在以下方面有所不同:

  • sharedBounds() 適用於視覺上的差異,但狀態之間應共用相同區域的內容,而 sharedElement() 會預期內容會是相同的內容。
  • 使用 sharedBounds() 時,在兩個狀態之間的轉換期間,就能看到進入和離開畫面的內容,而如果使用 sharedElement(),則只會在轉換的邊界內轉譯目標內容。Modifier.sharedBounds() 具有 enterexit 參數,用於指定內容轉換方式,與 AnimatedContent 的運作方式類似。
  • sharedBounds() 最常見的用途是容器轉換模式,而 sharedElement() 的範例用途則是主要轉換。
  • 使用 Text 可組合項時,建議採用 sharedBounds() 支援字型變更,例如在斜體和粗體轉換之間轉換,或是變更顏色。

從上一個範例中,在 RowColumn 的兩個不同情境中加入 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()
                )
                // ...

        ) {
            // ...
        }
    }
}

圖 5.兩個可組合項之間的共用邊界。

瞭解範圍

如要使用 Modifier.sharedElement(),可組合項必須位於 SharedTransitionScope 中。SharedTransitionLayout 可組合項提供 SharedTransitionScope。針對您要共用的元素,請務必將其放在 UI 階層中的同一個頂層點。

一般來說,可組合項也應置於 AnimatedVisibilityScope 內。這通常是透過 AnimatedContent 切換可組合項、直接使用 AnimatedVisibility 時提供,或者使用 NavHost 可組合函式提供 (除非您手動管理瀏覽權限)。若要使用多個範圍,請將所需範圍儲存在 CompositionLocal、使用 Kotlin 中的內容接收器,或將範圍做為參數傳遞至函式。

在您有多個範圍追蹤或包含深層巢狀階層的情況下,請使用 CompositionLocalsCompositionLocal 可讓您選擇要儲存及使用的確切範圍。另一方面,當您使用結構定義接收器時,階層中的其他版面配置可能會意外覆寫提供的範圍。舉例來說,如果有多個巢狀 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
        }
    )
}

圖 6:AnimatedVisibility 共用元素。

修飾符排序

使用 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 中,我們有下列共用元素:

圖 7.這張圖片顯示 Jetsnack 中每個 UI 部分都有註解。

您可以建立列舉來代表共用元素類型。在本範例中,整份點心資訊卡也可能會從主畫面的多個不同位置顯示,例如「熱門」和「推薦」區段。您可以建立具有 snackIdorigin (「熱門」/「建議」) 的鍵,以及要共用的共用元素的 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()

手動管理共用元素的瀏覽權限

如果您可能不使用 AnimatedVisibilityAnimatedContent,您可以自行管理共用元素的瀏覽權限。使用 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()