シーンを使用してカスタム レイアウトを作成する

Navigation 3 では、シーンを使用してアプリの UI フローを管理するための強力で柔軟なシステムが導入されています。シーンを使用すると、高度にカスタマイズされたレイアウトを作成したり、さまざまな画面サイズに適応したり、複雑なマルチペイン エクスペリエンスをシームレスに管理したりできます。

シーンについて

Navigation 3 では、Scene は 1 つ以上の NavEntry インスタンスをレンダリングする基本単位です。Scene は、バックスタックからのコンテンツの表示を保持して管理できる、UI の明確な視覚状態またはセクションと考えてください。

Scene インスタンスは、その keyScene 自体のクラスによって一意に識別されます。この一意の識別子は、Scene が変更されたときにトップレベルのアニメーションを駆動するため、非常に重要です。

Scene インターフェースには次のプロパティがあります。

  • key: Any: この特定の Scene インスタンスの一意の識別子。このキーは、主にアニメーションの目的で、Scene のクラスと組み合わせて明確性を確保します。
  • entries: List<NavEntry<T>>: Scene が表示する NavEntry オブジェクトのリストです。重要な点は、遷移中に同じ NavEntry が複数の Scenes に表示される(共有要素の遷移など)場合、そのコンテンツは、それを表示している最新のターゲット Scene によってのみレンダリングされることです。
  • previousEntries: List<NavEntry<T>>: このプロパティは、現在の Scene から「戻る」アクションが発生した場合に結果として得られる NavEntry を定義します。これは、適切な予測型「戻る」状態を計算するために不可欠です。これにより、NavDisplay は正しい前の状態を予測して遷移できます。この状態は、クラスやキーが異なる Scene である場合があります。
  • content: @Composable () -> Unit: これはコンポーズ可能な関数で、Sceneentries とその Scene に固有の周囲の UI 要素をレンダリングする方法を定義します。

シーン戦略を理解する

SceneStrategy は、バックスタック内の特定の NavEntry リストをどのように並べ替えて Scene に遷移させるかを決定するメカニズムです。基本的に、現在のバックスタック エントリが表示されると、SceneStrategy は次の 2 つの重要な質問を自問します。

  1. これらのエントリから Scene を作成できますか?SceneStrategy が、指定された NavEntry を処理して意味のある Scene(ダイアログやマルチペイン レイアウトなど)を形成できることを判断すると、処理を続行します。それ以外の場合は null を返します。これにより、他の戦略が Scene を作成できるようになります。
  2. そうである場合、それらのエントリを Scene? にどのように配置すればよいですか? SceneStrategy がエントリの処理にコミットすると、Scene の作成と、指定された NavEntry をその Scene 内にどのように表示するかを定義する責任を負います。

SceneStrategy のコアは calculateScene メソッドです。

@Composable
public fun calculateScene(
    entries: List<NavEntry<T>>,
    onBack: (count: Int) -> Unit,
): Scene<T>?

このメソッドは、バックスタックから現在の List<NavEntry<T>>onBack コールバックを受け取ります。指定されたエントリから正常に形成できる場合は Scene<T> を返します。そうでない場合は null を返します。

SceneStrategy には、複数の戦略を連結できる便利な then インフィックス関数も用意されています。これにより、各戦略が Scene の計算を試行し、計算できない場合はチェーンの次の戦略に委任できる、柔軟な意思決定パイプラインが作成されます。

シーンとシーン戦略の連携の仕組み

NavDisplay は、バックスタックを監視し、SceneStrategy を使用して適切な Scene を決定してレンダリングする中心的なコンポーザブルです。

NavDisplay's sceneStrategy パラメータは、表示する Scene の計算を行う SceneStrategy を想定しています。指定された戦略(または戦略のチェーン)で Scene が計算されない場合、NavDisplay はデフォルトで SinglePaneSceneStrategy を使用するように自動的にフォールバックします。

インタラクションの詳細は次のとおりです。

  • バックスタックにキーを追加または削除すると(backStack.add()backStack.removeLastOrNull() を使用するなど)、NavDisplay はこれらの変更を監視します。
  • NavDisplay は、現在の NavEntrys リスト(バックスタックキーから派生)を、設定された SceneStrategy's calculateScene メソッドに渡します。
  • SceneStrategyScene を正常に返すと、NavDisplay はその Scenecontent をレンダリングします。NavDisplay は、Scene のプロパティに基づいてアニメーションと予測型「戻る」も管理します。

例: シングルペイン レイアウト(デフォルトの動作)

最もシンプルなカスタム レイアウトは、シングルペイン ディスプレイです。これは、他の SceneStrategy が優先されない場合のデフォルトの動作です。

data class SinglePaneScene<T : Any>(
    override val key: T,
    val entry: NavEntry<T>,
    override val previousEntries: List<NavEntry<T>>,
) : Scene<T> {
    override val entries: List<NavEntry<T>> = listOf(entry)
    override val content: @Composable () -> Unit = { entry.content.invoke(entry.key) }
}

/**
 * A [SceneStrategy] that always creates a 1-entry [Scene] simply displaying the last entry in the
 * list.
 */
public class SinglePaneSceneStrategy<T : Any> : SceneStrategy<T> {
    @Composable
    override fun calculateScene(entries: List<NavEntry<T>>, onBack: (Int) -> Unit): Scene<T> =
        SinglePaneScene(
            key = entries.last().key,
            entry = entries.last(),
            previousEntries = entries.dropLast(1)
        )
}

例: 基本的な 2 ペイン レイアウト(カスタム シーンと戦略)

この例では、次の 2 つの条件に基づいて有効になるシンプルな 2 ペイン レイアウトを作成する方法を示します。

  1. ウィンドウの幅が 2 つのペインをサポートするのに十分な大きさである(少なくとも WIDTH_DP_MEDIUM_LOWER_BOUND)。
  2. バックスタックの上位 2 つのエントリは、特定のメタデータを使用して 2 ペイン レイアウトでの表示を明示的に宣言しています。

次のスニペットは、TwoPaneScene.ktTwoPaneSceneStrategy.kt のソースコードを組み合わせたものです。

// --- TwoPaneScene ---
/**
 * A custom [Scene] that displays two [NavEntry]s side-by-side in a 50/50 split.
 */
class TwoPaneScene<T : Any>(
    override val key: Any,
    override val previousEntries: List<NavEntry<T>>,
    val firstEntry: NavEntry<T>,
    val secondEntry: NavEntry<T>
) : Scene<T> {
    override val entries: List<NavEntry<T>> = listOf(firstEntry, secondEntry)
    override val content: @Composable (() -> Unit) = {
        Row(modifier = Modifier.fillMaxSize()) {
            Column(modifier = Modifier.weight(0.5f)) {
                firstEntry.content.invoke(firstEntry.key)
            }
            Column(modifier = Modifier.weight(0.5f)) {
                secondEntry.content.invoke(secondEntry.key)
            }
        }
    }

    companion object {
        internal const val TWO_PANE_KEY = "TwoPane"
        /**
         * Helper function to add metadata to a [NavEntry] indicating it can be displayed
         * in a two-pane layout.
         */
        fun twoPane() = mapOf(TWO_PANE_KEY to true)
    }
}

// --- TwoPaneSceneStrategy ---
/**
 * A [SceneStrategy] that activates a [TwoPaneScene] if the window is wide enough
 * and the top two back stack entries declare support for two-pane display.
 */
class TwoPaneSceneStrategy<T : Any> : SceneStrategy<T> {
    @OptIn(ExperimentalMaterial3AdaptiveApi::class, ExperimentalMaterial3WindowSizeClassApi::class)
    @Composable
    override fun calculateScene(
        entries: List<NavEntry<T>>,
        onBack: (Int) -> Unit
    ): Scene<T>? {

        val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass

        // Condition 1: Only return a Scene if the window is sufficiently wide to render two panes.
        // We use isWidthAtLeastBreakpoint with WIDTH_DP_MEDIUM_LOWER_BOUND (600dp).
        if (!windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) {
            return null
        }

        val lastTwoEntries = entries.takeLast(2)

        // Condition 2: Only return a Scene if there are two entries, and both have declared
        // they can be displayed in a two pane scene.
        return if (lastTwoEntries.size == 2 &&
            lastTwoEntries.all { it.metadata.containsKey(TwoPaneScene.TWO_PANE_KEY) }
        ) {
            val firstEntry = lastTwoEntries.first()
            val secondEntry = lastTwoEntries.last()

            // The scene key must uniquely represent the state of the scene.
            val sceneKey = Pair(firstEntry.key, secondEntry.key)

            TwoPaneScene(
                key = sceneKey,
                // Where we go back to is a UX decision. In this case, we only remove the top
                // entry from the back stack, despite displaying two entries in this scene.
                // This is because in this app we only ever add one entry to the
                // back stack at a time. It would therefore be confusing to the user to add one
                // when navigating forward, but remove two when navigating back.
                previousEntries = entries.dropLast(1),
                firstEntry = firstEntry,
                secondEntry = secondEntry
            )
        } else {
            null
        }
    }
}

この TwoPaneSceneStrategyNavDisplay で使用するには、entryProvider 呼び出しを変更して、2 ペイン レイアウトで表示するエントリの TwoPaneScene.twoPane() メタデータを含めます。次に、sceneStrategy として TwoPaneSceneStrategy() を指定し、シングルペイン シナリオのデフォルトのフォールバックを使用します。

// Define your navigation keys
@Serializable
data object ProductList : NavKey
@Serializable
data class ProductDetail(val id: String) : NavKey

@Composable
fun MyAppContent() {
    val backStack = rememberNavBackStack(ProductList)

    NavDisplay(
        backStack = backStack,
        entryProvider = entryProvider {
            entry<ProductList>(
                // Mark this entry as eligible for two-pane display
                metadata = TwoPaneScene.twoPane()
            ) { key ->
                Column {
                    Text("Product List")
                    Button(onClick = { backStack.add(ProductDetail("ABC")) }) {
                        Text("View Details for ABC (Two-Pane Eligible)")
                    }
                }
            }

            entry<ProductDetail>(
                // Mark this entry as eligible for two-pane display
                metadata = TwoPaneScene.twoPane()
            ) { key ->
                Text("Product Detail: ${key.id} (Two-Pane Eligible)")
            }
            // ... other entries ...
        },
        // Simply provide your custom strategy. NavDisplay will fall back to SinglePaneSceneStrategy automatically.
        sceneStrategy = TwoPaneSceneStrategy<Any>(),
        onBack = { count ->
            repeat(count) {
                if (backStack.isNotEmpty()) {
                    backStack.removeLastOrNull()
                }
            }
        }
    )
}

マテリアル アダプティブ シーンにリスト詳細コンテンツを表示する

リストと詳細のユースケースの場合、androidx.compose.material3.adaptive:adaptive-navigation3 アーティファクトは、リストと詳細の Scene を作成する ListDetailSceneStrategy を提供します。この Scene は、複雑なマルチペイン配置(リスト、詳細、追加ペイン)を自動的に処理し、ウィンドウ サイズとデバイスの状態に基づいて調整します。

マテリアル リストの詳細 Scene を作成する手順は次のとおりです。

  1. 依存関係を追加する: プロジェクトの build.gradle.kts ファイルに androidx.compose.material3.adaptive:adaptive-navigation3 を含めます。
  2. ListDetailSceneStrategy メタデータを使用してエントリを定義する: listPane(), detailPane()extraPane() を使用して、適切なペイン表示に NavEntrys をマークします。listPane() ヘルパーを使用すると、項目が選択されていない場合に detailPlaceholder を指定することもできます。
  3. rememberListDetailSceneStrategy を使用する(): このコンポーザブル関数は、NavDisplay で使用できる事前構成された ListDetailSceneStrategy を提供します。

次のスニペットは、ListDetailSceneStrategy の使用方法を示す Activity のサンプルです。

@Serializable
object ProductList : NavKey

@Serializable
data class ProductDetail(val id: String) : NavKey

@Serializable
data object Profile : NavKey

class MaterialListDetailActivity : ComponentActivity() {

    @OptIn(ExperimentalMaterial3AdaptiveApi::class)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            Scaffold { paddingValues ->
                val backStack = rememberNavBackStack(ProductList)
                val listDetailStrategy = rememberListDetailSceneStrategy<Any>()

                NavDisplay(
                    backStack = backStack,
                    modifier = Modifier.padding(paddingValues),
                    onBack = { keysToRemove -> repeat(keysToRemove) { backStack.removeLastOrNull() } },
                    sceneStrategy = listDetailStrategy,
                    entryProvider = entryProvider {
                        entry<ProductList>(
                            metadata = ListDetailSceneStrategy.listPane(
                                detailPlaceholder = {
                                    ContentYellow("Choose a product from the list")
                                }
                            )
                        ) {
                            ContentRed("Welcome to Nav3") {
                                Button(onClick = {
                                    backStack.add(ProductDetail("ABC"))
                                }) {
                                    Text("View product")
                                }
                            }
                        }
                        entry<ProductDetail>(
                            metadata = ListDetailSceneStrategy.detailPane()
                        ) { product ->
                            ContentBlue("Product ${product.id} ", Modifier.background(PastelBlue)) {
                                Column(horizontalAlignment = Alignment.CenterHorizontally) {
                                    Button(onClick = {
                                        backStack.add(Profile)
                                    }) {
                                        Text("View profile")
                                    }
                                }
                            }
                        }
                        entry<Profile>(
                            metadata = ListDetailSceneStrategy.extraPane()
                        ) {
                            ContentGreen("Profile")
                        }
                    }
                )
            }
        }
    }
}

図 1. マテリアル リストの詳細シーンで実行されるコンテンツの例。