共有要素遷移は、コンテンツ間で一貫したコンテンツを持つコンポーザブル間をシームレスに遷移する方法です。ナビゲーションによく使用され、ユーザーが画面間を移動する際に、異なる画面を視覚的に接続できるようにします。
たとえば、次の動画では、スナックの画像とタイトルがリストページから詳細ページまで共有されています。
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 } ) }
修飾子の順序
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 には次の共有要素があります。
共有要素タイプを表す列挙型を作成できます。この例では、スナックカード全体をホーム画面の複数の場所(人気セクションやおすすめセクションなど)から表示することもできます。共有される共有要素の 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()
を追加します。
- 共有画像コンポーザブル: