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

Navigation 3 では、Scene を通じてアプリの 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>> を取得する SceneStrategyScope の拡張関数です。提供されたエントリから正常に作成できる場合は Scene<T> を、作成できない場合は null を返す必要があります。

SceneStrategyScope は、SceneStrategy が必要とする可能性のある任意の引数(onBack コールバックなど)を維持する役割を担います。

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

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

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

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

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

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

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

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

data class SinglePaneScene<T : Any>(
    override val key: Any,
    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() }
}

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

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

この例では、次の 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()
            }
            Column(modifier = Modifier.weight(0.5f)) {
                secondEntry.Content()
            }
        }
    }

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

@Composable
fun <T : Any> rememberTwoPaneSceneStrategy(): TwoPaneSceneStrategy<T> {
    val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass

    return remember(windowSizeClass) {
        TwoPaneSceneStrategy(windowSizeClass)
    }
}

// --- 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>(val windowSizeClass: WindowSizeClass) : SceneStrategy<T> {
    override fun SceneStrategyScope<T>.calculateScene(entries: List<NavEntry<T>>): Scene<T>? {
        // 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.contentKey, secondEntry.contentKey)

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

NavDisplay でこの TwoPaneSceneStrategy を使用するには、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 = rememberTwoPaneSceneStrategy(),
        onBack = {
            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<NavKey>()

                NavDisplay(
                    backStack = backStack,
                    modifier = Modifier.padding(paddingValues),
                    onBack = { 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. マテリアル リスト詳細 Scene で実行されているコンテンツの例。