共有要素の遷移は、コンテンツが共通するコンポーザブルをシームレスに遷移する方法です。多くの場合、ナビゲーションに使用され、ユーザーが画面間を移動する際に、異なる画面を視覚的に接続できます。
たとえば、次の動画では、スナックの画像とタイトルがリスティング ページから詳細ページに共有されています。
Compose には、共有要素の作成に役立つハイレベル API がいくつかあります。
SharedTransitionLayout
: 共有要素の遷移を実装するために必要な最外側のレイアウト。SharedTransitionScope
を提供します。共有要素修飾子を使用するには、コンポーザブルがSharedTransitionScope
に含まれている必要があります。Modifier.sharedElement()
: 別のコンポーザブルと照合する必要があるコンポーザブルをSharedTransitionScope
にフラグ付けする修飾子。Modifier.sharedBounds()
: このコンポーザブルの境界を、遷移を行う場所のコンテナ境界として使用する必要があることをSharedTransitionScope
に報告する修飾子。sharedElement()
とは対照的に、sharedBounds()
は視覚的に異なるコンテンツ用に設計されています。
Compose で共有要素を作成する際の重要なコンセプトは、オーバーレイとクリッピングとの連携方法です。この重要なトピックについて詳しくは、クリップとオーバーレイのセクションをご覧ください。
基本的な使用方法
このセクションでは、小さな「リスト」アイテムから大きな詳細なアイテムに移行する次の移行を作成します。

Modifier.sharedElement()
を使用する最適な方法は、AnimatedContent
、AnimatedVisibility
、または NavHost
と組み合わせて使用することであり、これによりコンポーザブル間の遷移が自動的に管理されます。
共有要素を追加する前に、MainContent
と DetailsContent
コンポーザブルを持つ既存の基本的な AnimatedContent
から開始します。

AnimatedContent
を開始。2 つのレイアウト間で共有要素をアニメーション化するには、
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 ) } } }
一致する 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
に対してクエリを実行します。
自動アニメーションは次のようになります。

コンテナ全体の背景色とサイズは、デフォルトの AnimatedContent
設定のままです。
共有境界と共有要素
Modifier.sharedBounds()
は Modifier.sharedElement()
に似ています。ただし、修飾子には次のような違いがあります。
sharedBounds()
は、視覚的には異なるが、状態間で同じ領域を共有する必要があるコンテンツ用です。これに対して、sharedElement()
はコンテンツが同じであることを想定しています。sharedBounds()
では、画面に出入りしたコンテンツは 2 つの状態間の遷移中に表示されますが、sharedElement()
では、ターゲット コンテンツのみが変換境界でレンダリングされます。Modifier.sharedBounds()
には、AnimatedContent
と同様に、コンテンツの遷移方法を指定するenter
パラメータとexit
パラメータがあります。sharedBounds()
の最も一般的なユースケースはコンテナ変換パターンですが、sharedElement()
のユースケースの例はヒーロー遷移です。Text
コンポーザブルを使用する場合は、斜体と太字の切り替えや色の変更などのフォント変更をサポートするためにsharedBounds()
を使用することをおすすめします。
前の例から、2 つの異なるシナリオで Row
と Column
に Modifier.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() ) // ... ) { // ... } } }
スコープについて理解する
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 } ) }
AnimatedVisibility
の共有要素。修飾子の順序
Modifier.sharedElement()
と Modifier.sharedBounds()
では、Compose の他の部分と同様に、修飾子チェーンの順序が重要です。サイズに影響する修飾子を間違って配置すると、共有要素の照合中に予期しない視覚的なジャンプが発生する可能性があります。
たとえば、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 には次の共有要素があります。

共有要素型を表す列挙型を作成できます。この例では、スナックカード全体をホーム画面の複数の場所(「人気」セクションや「おすすめ」セクションなど)から表示することもできます。共有する共有要素の 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 にはいくつかの制限があります。主な変更点は次のとおりです。
- ビューと Compose 間の相互運用性はサポートされていません。これには、
AndroidView
をラップするコンポーザブル(Dialog
など)が含まれます。 - 次のアニメーションは自動的にサポートされません。
- 共有イメージ コンポーザブル:
ContentScale
はデフォルトではアニメーション化されません。セット終了のContentScale
にスナップされます。
- シェイプのクリッピング - シェイプ間の自動アニメーション(アイテムの遷移に伴う正方形から円へのアニメーションなど)は組み込みでサポートされていません。
- サポートされていないケースの場合は、
sharedElement()
の代わりにModifier.sharedBounds()
を使用し、アイテムにModifier.animateEnterExit()
を追加します。
- 共有イメージ コンポーザブル: