共有要素の遷移は、コンテンツが共通するコンポーザブルをシームレスに遷移する方法です。ナビゲーションによく使用され、ユーザーが画面間を移動するときに、さまざまな画面を視覚的に接続できます。
たとえば、次の動画では、スナックの画像とタイトルがリスティング ページから詳細ページに共有されています。
Compose には、共有要素の作成に役立ついくつかのハイレベル API があります。
SharedTransitionLayout
: 共有要素の遷移を実装するために必要な最外側のレイアウト。SharedTransitionScope
を提供します。共有要素修飾子を使用するには、コンポーザブルをSharedTransitionScope
に配置する必要があります。Modifier.sharedElement()
: 別のコンポーザブルと照合する必要があるコンポーザブルをSharedTransitionScope
にフラグ付けする修飾子。Modifier.sharedBounds()
: このコンポーザブルの境界が、遷移が行われるコンテナの境界として使用されるべきであることをSharedTransitionScope
に通知する修飾子。sharedElement()
とは対照的に、sharedBounds()
は視覚的に異なるコンテンツ用に設計されています。
Compose で共有要素を作成する際の重要なコンセプトは、オーバーレイとクリッピングとの連携方法です。この重要なトピックについて詳しくは、クリッピングとオーバーレイのセクションをご覧ください。
基本的な使用方法
このセクションでは、小さい「リスト」アイテムから大きな詳細アイテムに遷移する次の遷移を作成します。
Modifier.sharedElement()
を使用する最適な方法は、AnimatedContent
、AnimatedVisibility
、または NavHost
と組み合わせて使用することであり、これによりコンポーザブル間の遷移が自動的に管理されます。
出発点は、共有要素を追加する前に MainContent
と DetailsContent
コンポーザブルを持つ既存の基本的な 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 } ) }
修飾子の順序
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 間の相互運用性はサポートされていません。これには、
Dialog
など、AndroidView
をラップするコンポーザブルが含まれます。 - 以下のアニメーションは自動的にサポートされません。
- 共有イメージ コンポーザブル:
ContentScale
はデフォルトではアニメーション化されません。設定した終点ContentScale
にスナップします。
- シェイプのクリッピング - シェイプ間の自動アニメーション(アイテムの遷移に伴う正方形から円へのアニメーションなど)は組み込みでサポートされていません。
- サポートされていないケースの場合は、
sharedElement()
の代わりにModifier.sharedBounds()
を使用し、アイテムにModifier.animateEnterExit()
を追加します。
- 共有イメージ コンポーザブル: