Compose の共有要素遷移

共有要素遷移は、コンテンツ間で一貫したコンテンツを持つコンポーザブル間をシームレスに遷移する方法です。ナビゲーションによく使用され、ユーザーが画面間を移動する際に、異なる画面を視覚的に接続できるようにします。

たとえば、次の動画では、スナックの画像とタイトルがリストページから詳細ページまで共有されています。

図 1.Jetsnack 共有要素のデモ

Compose には、共有要素の作成に役立つハイレベル API がいくつかあります。

  • SharedTransitionLayout: 共有要素遷移を実装するために必要な、最も外側のレイアウト。SharedTransitionScope を提供します。共有要素修飾子を使用するには、コンポーザブルを SharedTransitionScope に含める必要があります。
  • Modifier.sharedElement(): 別のコンポーザブルと一致するコンポーザブルを SharedTransitionScope にフラグする修飾子。
  • Modifier.sharedBounds(): このコンポーザブルの境界を遷移が行われるコンテナ境界として使用する必要があることを SharedTransitionScope にフラグを立てる修飾子。sharedElement() とは対照的に、sharedBounds() は視覚的に異なるコンテンツに対応するように設計されています。

Compose で共有要素を作成する際の重要なコンセプトは、オーバーレイやクリッピングの動作です。この重要なトピックについて詳しくは、クリッピングとオーバーレイのセクションをご覧ください。

基本的な使用方法

このセクションには、小さい「リスト」アイテムから大きい詳細アイテムに移行する以下の遷移が作成されます。

図 2.2 つのコンポーザブル間の共有要素遷移の基本的な例

Modifier.sharedElement() を使用する最適な方法は、AnimatedContentAnimatedVisibility、または NavHost と組み合わせて使用することです。これにより、コンポーザブル間の移行が自動的に管理されます。

出発点は、共有要素を追加する前に、MainContentDetailsContent コンポーザブルを持つ既存の基本的な AnimatedContent です。

図 3.共有要素遷移なしで AnimatedContent を開始する。

  1. 共有要素を 2 つのレイアウト間でアニメーション化するには、AnimatedContent コンポーザブルを SharedTransitionLayout で囲みます。SharedTransitionLayoutAnimatedContent のスコープが 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. 一致する 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.2 つのコンポーザブル間の共有要素遷移の基本的な例

コンテナ全体の背景色とサイズには、引き続きデフォルトの AnimatedContent 設定が使用されます。

境界の共有と共有要素

Modifier.sharedBounds()Modifier.sharedElement() に似ています。 ただし、修飾子は次のように異なります。

  • sharedBounds() は、視覚的には異なるものの、状態間で同じ領域を共有するコンテンツに使用します。一方、sharedElement() はコンテンツが同じであると想定します。
  • sharedBounds() では、画面に出入りするコンテンツは 2 つの状態間の遷移中に表示されますが、sharedElement() では、ターゲット コンテンツのみが変換境界でレンダリングされます。Modifier.sharedBounds() には、AnimatedContent の仕組みと同様に、コンテンツの移行方法を指定する enter パラメータと exit パラメータがあります。
  • sharedBounds() の最も一般的なユースケースはコンテナ変換パターンですが、sharedElement() のユースケースの例はヒーロー遷移です。
  • Text コンポーザブルを使用する場合は、斜体と太字間の遷移や色の変更などのフォントの変更をサポートするため、sharedBounds() をおすすめします。

前の例のように、2 つの異なるシナリオで RowColumnModifier.sharedBounds() を追加すると、2 つの境界を共有し、遷移アニメーションを実行して、相互に成長させることができます。

@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.2 つのコンポーザブル間の共有境界

スコープについて

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
        }
    )
}

図 6. AnimatedVisibility と共有する要素

修飾子の順序

Compose の他の部分と同様に、Modifier.sharedElement()Modifier.sharedBounds() では修飾子の順序が重要です。サイズに影響する修飾子の配置が正しくないと、共有要素のマッチング中に予期しない視覚的なジャンプが発生することがあります。

たとえば、2 つの共有要素の異なる位置にパディング修飾子を配置すると、アニメーションに視覚的な違いが生じます。

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.UI の各部分のアノテーションが付いた Jetsnack の画像。

共有要素タイプを表す列挙型を作成できます。この例では、スナックカード全体をホーム画面の複数の場所(人気セクションやおすすめセクションなど)から表示することもできます。共有される共有要素の 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() を実装しているため、データクラスをおすすめします。

共有要素の公開設定を手動で管理する

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 にはいくつかの制限があります。特に注目すべき点は次のとおりです。

  • ビューと Compose の相互運用はサポートされていません。これには、Dialog など、AndroidView をラップするコンポーザブルが含まれます。
  • 次の場合、自動アニメーションはサポートされていません。
    • 共有画像コンポーザブル:
      • ContentScale はデフォルトではアニメーション化されません。設定された終点の ContentScale にスナップされます。
    • シェイプのクリッピング - シェイプ間の自動アニメーション(たとえば、アイテムの遷移に伴う四角形から円へのアニメーション化)の組み込みサポートはありません。
    • サポートされていない場合は、sharedElement() ではなく Modifier.sharedBounds() を使用し、項目に Modifier.animateEnterExit() を追加します。