飴屋

Kotlin/NavControllerで遷移

ドロワーを持つレイアウトを作ろうと思ったけど、現在のScaffoldだけではできなくなってて、ModalNavigationDrawerで囲ってやればよかったんだけど、そもそもComposableな関数の中でクリックに応じて、画面が遷移するってどう書くのよ?と前回、詰まったのでした。Composableな関数に画面遷移をべた書きできなくもないですが、そうすると他の画面でその関数は使いまわせなくなるので、Jetpack Composeの便利さを犠牲にしてしまいそうです。

ナビゲーション コントローラとナビゲーション グラフ

NavControllerというクラスを使ってアプリ内の遷移に関する情報を集約できると聞きました。このコンセプトはComposableな関数にとてもふさわしそうです。このコントローラーを引数に渡しさえすれば、どこからでも好きに画面を遷移できそうです。そして、このコントローラーは前々回にすでに出てきてました。前の画面に戻るボタンを実装したかったけど、遷移情報がなくて何もできなかっただけですけど・・・。こういう遷移情報・・・接続情報をナビゲーション グラフと呼ぶんですかね。画面と画面をつなぐようなイメージでいますが、つなげるのは画面だけではなく、例えばダイアログを表示させることなんかも遷移にあたるようです。そういうつなげられるものがデスティネーションなんて呼ばれてましたが、横文字がピンとこない。Windowsだったらウィンドウ、コンポーネント?destination で引くと「行き先」「目的地」「宛先」。プログラマーだとファイルやメモリのコピー先をdestみたいな省略表現で見かける人も多いですかね。まぁ、あんまり深く考えず、遷移を線と考えたらデスティネーションは端点です。点を線で結んでできたグラフが遷移図になるってイメージですかね。

val navController = rememberNavController()

ナビゲーション コントローラはこんな風に思い出して使うものだそうです。複数のクラス間で使われるものなので、こうやって取得するんですね。グローバル変数絶対許さない教は大変なのです。rememberされたnavController は十分上位のComposable関数の中で作られ、下階層に伝播させながら使っていくようです。少なくともModalNavigationDrawerでは必要なので、この中か、それより上位のComposableでナビゲーション コントローラが用意されるわけです。

Jetpack ComposeではないViews UI フレームワークなレイアウトでもナビゲーション コントローラは使えるそうです。

  • Fragment.findNavController()
  • View.findNavController()
  • Activity.findNavController(viewId: Int)

これみて思い出したんですが、前にfindNavControllerって使ったことありましたね。フラグメントを使うようになっていくんだ!ってなってきた頃だったので、サンプルを見ながらナビゲーションもXMLで定義してましたね。あれをComposeでもやるのかな?

NavHost

そういえば以前フラグメントで画面を構成しているときに、Activityが実質一つになっちゃうんだなぁ、って思ったのを思い出しました。画面の遷移はActivityの遷移ではなく、フラグメントの再構成なんだなってそのとき気付いたのを忘れてました。(何年前の話だっけ)今、複数のActivityをいったりきたりするような古いアプリをComposableにしようとしているのですが、Composableは画面のレイアウトだけにとどまらず、画面の切り替えにも関わるもっと壮大なコンセプトを持っていたんですね。というわけで、画面の遷移を定義するNavHostっていうComposable部品を導入してみます。その性質的に、Composableの構造の根っこ(ルート)に近いところに配置せざるをえませんので、setContent 直下に生やしてみました。

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    enableEdgeToEdge()
    setContent {
        val navController = rememberNavController()
        NavHost(navController = navController, startDestination = "main") {
            composable("main") { MainScreen(navController) }
            composable("sub") { SubScreen(navController) }
        }
    }
}

rememberしたnavController もここで登場させます。main と sub っていうデスティネーションが定義され、それぞれの実体であるComposable関数(MainScreen, SubScreen)を関連付けてあります。

<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/nav_graph"
    app:startDestination="@id/ListFragment">
    <fragment
        android:id="@+id/ListFragment"
        android:name="hoge.ListFragment"
        android:label="@string/_list_fragment_label"
        tools:layout="@layout/fragment_list">
        <action
            android:id="@+id/actionListFragment_to_MemoryFragment"
            app:enterAnim="@anim/slide_from_left"
            app:exitAnim="@anim/slide_to_right"
            app:destination="@id/MemoryFragment" />
        ...
    </fragment>
    ...
</navigation>

前にXMLでナビゲーション グラフを定義してたときは、↑フラグメントのことを書いてましたが、今はComposableのことを書くわけです。共通するのはstartDestination つまり最初のデスティネーション、スタート地点を指定するところですね。スタートはやはりmain のMainScreenです。

そういえば、テーマを扱うComposable関数ってありますが、配置的にNavHostとどちらを上位に配置すべきでしょうか?とりあえずGeminiに聞いてみたら、MyAppTheme的なテーマ定義Composableを上位に配置するのが推奨されるそうです。理由はテーマは各ディスティネーション(画面)で共通することが定義されるのだから、NavHostで分岐させるより先に定義した方がいいからだそうえす。ですよね~。思った通りの答えが返ってきて安心しました。

NavigationDrawerItem(
    label = { Text(text = stringResource(R.string.action_hoge)) },
    selected = false,
    icon = { Icon(painterResource(R.drawable.ic_hoge), contentDescription = null)},
    onClick = {
        navController.navigate("sub")
    }
)

それでは最低限のNavHost を作り、navController もリメンバーしているので、ドロワーに配置したナビゲーションアイテム(NavigationDrawerItem)がクリックされたら画面が遷移するようにしてみましょう。onClick の中でnavController.navigate を呼ぶだけですね。今回は「sub」と名前を付けたデスティネーションの名前で遷移先を指定します。・・・クリックしたら、MainScreenに変わってSubScreenが呼び出されて、画面がガラッと変わりました。(成功!)

TopAppBar(
    title = { Text(stringResource(R.string.app_name))},
    navigationIcon = {
        if (navController.previousBackStackEntry != null) {
            IconButton(onClick = { navController.navigateUp() }) {
                Icon(
                    painter = rememberVectorPainter(image = Icons.AutoMirrored.Filled.ArrowBack),
                    contentDescription = stringResource(R.string.back),
                    modifier = Modifier.padding(4.dp)
                )
            }
        }
    },
)

移動した先のSubScreenにもScaffoldを置いて、topBarスロットにTopAppBarを配置し、その中のnavigationIconスロットに「戻る」ボタンを配置しました。navController.previousBackStackEntryを使うと戻る対象である一つ前のデスティネーションが存在するか確認できましたので、戻れるときだけ戻るボタンを出すことができました。ボタンがクリックされたらnavController.navigateUpを呼び出せば、一つ前の「main」デスティネーションが再び描画されました。

一覧へ