目次

目次

【Android】Navigation3のScene/SceneStrategyを用いて端末横幅に応じて表示方法を変えるアプリを作る

アバター画像
深沢雛子
アバター画像
深沢雛子
最終更新日2025/12/01 投稿日2025/12/03

はじめに

この記事は レコチョク Advent Calendar 2025 の3日目の記事となります。 

こんにちは、Androidアプリ開発エンジニアの深沢です。 この間RIP SLYMEさんのライブに初めて行ったらすごく楽しかったので、ヒップホップを色々聴いてみたいなと思っているところです。おすすめあったら教えて下さい。

先日参加してきたDroidKaigi2025Navigation 2 を 3 に移行する(予定)ためにやったことというセッションを聞いてきました。以前Navigation3について簡単に触ったことがあったのですが、セッションで説明していたSceneやSceneStrategyについてはよく知らなかったので簡単なアプリを作成してみました。 今回NavDisplay()などNavigation3のScene周り以外については省略していますのでご了承ください。

Scene/SceneStrategyとは

まずはじめにSceneとSceneStrategyとは何か簡単に説明します。

公式の API Referenceを以下に添付しますので、併せてご確認ください。

SceneはNavEntry(=キーとそのキーに紐づいたコンテンツを保持しているクラス)をどのように描画するかを決定するものです。Navigation3では1画面上に複数のNavEntryを配置することができるようです。 一方SceneStrategyはバックスタック内の特定の NavEntryリストをどのように並べ替えて Sceneに遷移させるかを決定するメカニズムのことです。シーンの表示、非表示、遷移は、SceneStrategyによって制御されます。 平たく言うとSceneが表示の仕方を指定し、SceneStrategyがどのようなときにどのようなSceneを使用するかを指定するクラスだと認識しています。

このScene/SceneStrategyを用いて簡単なアプリを作成してみました。

ボタンを押すとidを詳細画面に渡して表示させるというシンプルなアプリになっています。 縦長の一般的な端末だとただの画面遷移のアプリなのですが、

fold.gif

上記のようにFold端末などの横幅の長い端末でビルドすると詳細画面が画面の右側に表示され、画面遷移がなくなります。

実装詳細

まず、app/build.gradleに以下を追加してsyncします。バージョンは各自確認してください。

implementation("androidx.navigation3:navigation3-runtime:1.0.0-alpha09")
implementation("androidx.navigation3:navigation3-ui:1.0.0-alpha09")
implementation ("androidx.compose.material3.adaptive:adaptive-navigation3:1.0.0-alpha02")

続いてSceneを継承したTwoPaneSceneクラスを作成します。 Rowで2つのNavEntryを横並べにしています。また、twoPane()を呼び出すことでこのSceneを使えるようにします。

class TwoPaneScene(
    override val key: Any,
    override val previousEntries: List<NavEntry>,
    val firstEntry: NavEntry,
    val secondEntry: NavEntry
) : Scene {
    override val entries: List<NavEntry> = 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"
        fun twoPane() = mapOf(TWO_PANE_KEY to true)
    }
}

次にSceneStrategyを継承したTwoPaneSceneStrategyクラスを作成します。 calculateScene()という指定されたEntryに応じたSceneを返す関数をoverrideします。

まず、画面の横幅が狭いときは早期リターンさせます。 次に引数entriesの最後2つのEntryのmetadataがTwoPaneキーを持っているか確認します。どちらもTwoPaneキーを持っている場合のみTwoPaneSceneを返します。

class TwoPaneSceneStrategy : SceneStrategy {
    @Composable
    override fun calculateScene(
        entries: List<NavEntry>,
        onBack: (Int) -> Unit
    ): Scene? {

        val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
        if (!windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) { // 画面の横幅が狭いときはreturn null
            return null
        }

        val lastTwoEntries = entries.takeLast(2)

        return if (lastTwoEntries.size == 2 &&
            lastTwoEntries.all { it.metadata.containsKey(TwoPaneScene.TWO_PANE_KEY) }
        ) { // 最後2つのEntryどちらにもTwoPaneキーがある場合のみTwoPaneSceneを返す
            val firstEntry = lastTwoEntries.first()
            val secondEntry = lastTwoEntries.last()
            val sceneKey = Pair(firstEntry.contentKey, secondEntry.contentKey)

            TwoPaneScene(
                key = sceneKey,
                previousEntries = entries.dropLast(1),
                firstEntry = firstEntry,
                secondEntry = secondEntry
            )
        } else {
            null
        }
    }
}

2つのクラスが作成できたら、Compose関数などを作成していきます。 まず、各画面用のNavKeyを継承させたオブジェクトとクラスを作成します。 詳細画面ではidを受け取りたいので、コンストラクタにidを取ります。 @Serializableは画面の状態を保存、復元できるようにするため付けています。

@Serializable
object ListKey : NavKey

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

そして、Compose関数を作成していきます。 まずはListKeyを初期画面に設定したbackStackを作成します。

@Composable
fun MyApp() {
    val backStack: NavBackStack = rememberNavBackStack(ListKey)

次にNavDisplay()を書きます。sceneStrategyには先程作成したTwoPaneStrategyを指定します。

NavDisplay(
    backStack = backStack,
    modifier = Modifier
        .statusBarsPadding(),
    onBack = { keysToRemove -> repeat(keysToRemove) { backStack.removeLastOrNull() } },
    sceneStrategy = TwoPaneSceneStrategy(),

NavDisplay()にはentryProviderという引数があります。ここで各ナビゲーションキーに対応する画面の定義を行います。 どちらのKeyでもmetadataでtwoPane()を呼び出して2ペインに対応させるようにします。こうすることで横幅が大きい端末では横並べで2つのEntryの内容を表示することができます。

entryProvider = entryProvider {
    entry(
        metadata = TwoPaneScene.twoPane()
    ) {
        Column(Modifier.fillMaxSize()) {
            Text("リスト画面", style = MaterialTheme.typography.titleLarge)
            for (i in 1..5) {
                Button(onClick = {
                    backStack.removeAll { it is DetailKey }
                    backStack.add(DetailKey("id=$i"))
                }) {
                    Text("$i")
                }
            }
        }
    }
    entry(
        metadata = TwoPaneScene.twoPane()
    ) { product ->
        Column(
            Modifier
                .fillMaxSize()
                .padding(16.dp)
        ) {
            Text("詳細画面: ${product.id}", style = MaterialTheme.typography.titleLarge)
            Spacer(Modifier.height(16.dp))
            Button(onClick = {
                backStack.removeLastOrNull()
            }) {
                Text("リストへ戻る")
            }
        }
    }
}

MyApp()の全体は以下のとおりです。

@Composable
fun MyApp() {
    val backStack: NavBackStack = rememberNavBackStack(ListKey)

    NavDisplay(
        backStack = backStack,
        modifier = Modifier
            .statusBarsPadding(),
        onBack = { keysToRemove -> repeat(keysToRemove) { backStack.removeLastOrNull() } },
        sceneStrategy = TwoPaneSceneStrategy(),
        entryProvider = entryProvider {
            entry(
                metadata = TwoPaneScene.twoPane()
            ) {
                Column(Modifier.fillMaxSize()) {
                    Text("リスト画面", style = MaterialTheme.typography.titleLarge)
                    for (i in 1..5) {
                        Button(onClick = {
                            backStack.removeAll { it is DetailKey }
                            backStack.add(DetailKey("id=$i"))
                        }) {
                            Text("$i")
                        }
                    }
                }
            }
            entry(
                metadata = TwoPaneScene.twoPane()
            ) { product ->
                Column(
                    Modifier
                        .fillMaxSize()
                        .padding(16.dp)
                ) {
                    Text("詳細画面: ${product.id}", style = MaterialTheme.typography.titleLarge)
                    Spacer(Modifier.height(16.dp))
                    Button(onClick = {
                        backStack.removeLastOrNull()
                    }) {
                        Text("リストへ戻る")
                    }
                }
            }
        }
    )
}

MainActivityでMyApp()を呼び出すとgifのように画面サイズに応じて表示を変更できるようになります。

fold.gif
fold.gif

さいごに

Scene/SceneStrategyを用いてアプリを作成してみました。端末の横幅を見て表示方法を変えるアプリというものは今まで作成したことがなかったのですが、Navigation3を使えば案外簡単に実装できました。

明日の レコチョク Advent Calendar 2025 は4日目「AI時代、新卒採用を辞めちゃって本当に大丈夫?」です。お楽しみに!

参考

アバター画像

深沢雛子

目次