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

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

例: 基本的なリストと詳細のレイアウト(カスタムの Scene と戦略)

この例では、次の 2 つの条件に基づいてアクティブ化されるシンプルなリスト詳細レイアウトを作成する方法を示します。

  1. ウィンドウの幅が、2 つのペインをサポートするのに十分な幅(WIDTH_DP_MEDIUM_LOWER_BOUND 以上)である。
  2. バックスタックには、特定のメタデータを使用してリストと詳細のレイアウトで表示されることを宣言したエントリが含まれています。

次のスニペットは ListDetailScene.kt のソースコードで、ListDetailSceneListDetailSceneStrategy の両方を含んでいます。

// --- ListDetailScene ---
/**
 * A [Scene] that displays a list and a detail [NavEntry] side-by-side in a 40/60 split.
 *
 */
class ListDetailScene<T : Any>(
    override val key: Any,
    override val previousEntries: List<NavEntry<T>>,
    val listEntry: NavEntry<T>,
    val detailEntry: NavEntry<T>,
) : Scene<T> {
    override val entries: List<NavEntry<T>> = listOf(listEntry, detailEntry)
    override val content: @Composable (() -> Unit) = {
        Row(modifier = Modifier.fillMaxSize()) {
            Column(modifier = Modifier.weight(0.4f)) {
                listEntry.Content()
            }
            Column(modifier = Modifier.weight(0.6f)) {
                detailEntry.Content()
            }
        }
    }
}

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

    return remember(windowSizeClass) {
        ListDetailSceneStrategy(windowSizeClass)
    }
}

// --- ListDetailSceneStrategy ---
/**
 * A [SceneStrategy] that returns a [ListDetailScene] if the window is wide enough, the last item
 * is the backstack is a detail, and before it, at any point in the backstack is a list.
 */
class ListDetailSceneStrategy<T : Any>(val windowSizeClass: WindowSizeClass) : SceneStrategy<T> {

    override fun SceneStrategyScope<T>.calculateScene(entries: List<NavEntry<T>>): Scene<T>? {

        if (!windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) {
            return null
        }

        val detailEntry =
            entries.lastOrNull()?.takeIf { it.metadata.containsKey(DETAIL_KEY) } ?: return null
        val listEntry = entries.findLast { it.metadata.containsKey(LIST_KEY) } ?: return null

        // We use the list's contentKey to uniquely identify the scene.
        // This allows the detail panes to be displayed instantly through recomposition, rather than
        // having NavDisplay animate the whole scene out when the selected detail item changes.
        val sceneKey = listEntry.contentKey

        return ListDetailScene(
            key = sceneKey,
            previousEntries = entries.dropLast(1),
            listEntry = listEntry,
            detailEntry = detailEntry
        )
    }

    companion object {
        internal const val LIST_KEY = "ListDetailScene-List"
        internal const val DETAIL_KEY = "ListDetailScene-Detail"

        /**
         * Helper function to add metadata to a [NavEntry] indicating it can be displayed
         * as a list in the [ListDetailScene].
         */
        fun listPane() = mapOf(LIST_KEY to true)

        /**
         * Helper function to add metadata to a [NavEntry] indicating it can be displayed
         * as a list in the [ListDetailScene].
         */
        fun detailPane() = mapOf(DETAIL_KEY to true)
    }
}

NavDisplay でこの ListDetailSceneStrategy を使用するには、entryProvider 呼び出しを変更して、リスト レイアウトとして表示するエントリの ListDetailScene.listPane() メタデータと、詳細レイアウトとして表示するエントリの ListDetailScene.detailPane() を含めます。次に、ListDetailSceneStrategy()sceneStrategy として指定し、シングルペイン シナリオのデフォルトのフォールバックに依存します。

// Define your navigation keys
@Serializable
data object ConversationList : NavKey

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

@Composable
fun MyAppContent() {
    val backStack = rememberNavBackStack(ConversationList)
    val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>()

    NavDisplay(
        backStack = backStack,
        onBack = { backStack.removeLastOrNull() },
        sceneStrategy = listDetailStrategy,
        entryProvider = entryProvider {
            entry<ConversationList>(
                metadata = ListDetailSceneStrategy.listPane()
            ) {
                Column(modifier = Modifier.fillMaxSize()) {
                    Text(text = "I'm a Conversation List")
                    Button(onClick = { backStack.addDetail(ConversationDetail("123")) }) {
                        Text(text = "Open detail")
                    }
                }
            }
            entry<ConversationDetail>(
                metadata = ListDetailSceneStrategy.detailPane()
            ) {
                Text(text = "I'm a Conversation Detail")
            }
        }
    )
}

private fun NavBackStack<NavKey>.addDetail(detailRoute: ConversationDetail) {

    // Remove any existing detail routes, then add the new detail route
    removeIf { it is ConversationDetail }
    add(detailRoute)
}

独自のリスト詳細シーンを作成したくない場合は、次のセクションで示すように、適切な詳細とプレースホルダのサポートが付属している Material リスト詳細シーンを使用できます。

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

リストと詳細のユースケースでは、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 で実行されているコンテンツの例。