AndroidX Navigation による画面遷移の実装まとめ

使う際に見つけやすいようにまとめておきます。
基本的に Kotlin のことしか書いていないので、 Java の場合は[ここ](https://developer.android.com/guide/navigation/navigation-getting-started)を参考にしてください。

# 導入

## Navigation

* build.gradle (モジュール)

```groovy
dependencies {
    def nav_version = "【任意のバージョン】"

    implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
    implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
}
```

## Safe Args

* build.gradle (プロジェクト)

```groovy
dependencies {
    def nav_version = "【任意のバージョン】"
    classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"
}
```

* build.gradle (モジュール)

```groovy
apply plugin: "androidx.navigation.safeargs.kotlin"
```

# Navigation Component の構成要素

| 要素 | 説明 |
|:--|:--|
| Destination | 各画面に相当する Fragment |
| Action | 画面遷移を実現する Destination 同士の繋がり |
| NavHost | 画面のコンテナ。この中で画面が差し替えられて画面遷移が成立 |
| NavController | NavHost の画面遷移をコントロール |

# 実装

## 手順

* 1つの Activity/Fragment は 1つの Navigation graph しか使えない。
* Activity を Destination に設定するのは可能。その後の遷移はその Activity の Navigation graph に定義する
    * 単一の Navigation graph に複数の Activity を含めた遷移は定義できない

1. Navigation graph (XML) を作成
    1. res でコンテキストメニューを開く
    1. "New" > "Android Resource File"
    1. "Resource Type" に Navigation を選択、ファイル名を入力して「OK」
1. Activity/Fragment に NavHost を配置
    * &lt;fragment&gt;タグで配置
    * NavHostFragment#create() で生成した NavHostFragment を FragmentTransaction で配置
1. Navigation graph に Destination と Action を定義
1. NavController#navigate() で遷移を実行
    * Action の ID を指定して実行
    * Fragment の ID を指定して実行
    * Safe Args で Direction から生成した Action オブジェクトを指定して実行

## NavHost の配置

大抵の場合、[NavHostFragment](https://developer.android.com/reference/androidx/navigation/fragment/NavHostFragment?hl=ja) を使えば大丈夫だと思う。

### &lt;fragment&gt; タグで配置

```xml
<fragment
    android:id="@+id/(1)"
    android:name="androidx.navigation.fragment.NavHostFragment"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:defaultNavHost="(2)"
    app:navGraph="@navigation/(3)" />
```
1. &lt;fragment&gt; に付与するID。たとえ使わない場合でも、 **IDを指定しないとその画面に遷移した途端に『java.lang.IllegalArgumentException: No view found for id...』でクラッシュする**
1. バックキーとバックスタックの制御を NavHost に任せるか。次項を参照
1. res/navigation にある Navigation graph

### FragmentContainerView で配置してコードで取り出す

&lt;fragment&gt; で配置すると Android Studio のバージョンや設定次第では『Replace the <fragment> tag with FragmentContainerView.』と警告される場合がある。その場合はこの方法で対処してもよい。

```xml
<!-- (1) android:name ではなく class 要素で NavHostFragment を指定 -->
<androidx.fragment.app.FragmentContainerView
    class="androidx.navigation.fragment.NavHostFragment"
    android:id="@+id/navHost"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:defaultNavHost="(2)"
    app:navGraph="@navigation/(3)" />
```

```kotlin
// (4)
val navController: NavController =
    (supportFragmentManager.findFragmentById(R.id.navHost as NavHostFragment).navController
```

1. 配置する Fragment のクラス名は **class 要素** にセット
1. 前項と同じ。バックキーとバックスタックの制御を NavHost に任せるか
1. 前項と同じ。res/navigation にある Navigation graph
1. FragmentContainerView に配置した NavHostFragment を取り出して、そこから navController を取得する

#### `app:defaultNavHost` の true or false

`app:defaultNavHost` の値による挙動の違いは以下の通り。

|app:defaultNavHost の値|バックスタック|バックキー押下時|
|:--|:--|:--|
|true|遷移すると積まれる|バックスタックを pop 。無くなったら Activity/Fragment が終了|
|false|遷移すると積まれる|バックスタックに積まれた数に関わらず、 Activity/Fragment が即終了|

* アプリのスタートとなる画面が明確に決まっており、バックキーの操作は以下の通りでよいなら true にするのが適当
    * スタートの画面に戻るまでは前の画面に戻る（バックスタックを pop する）
    * スタートの画面にいるならアプリが終了する
* アプリのUIにタブがある場合など、アプリのスタートは決まっているが、スタートの画面以外でもアプリが終わる可能性があるなら false にして自分で制御した方がよい
    * false でもバックスタックは積まれるので、 `NavController#popBackStack()` での判断はできる（3タブの画面でやるなら↓のような感じ）

    ```kotlin
    override fun onBackPressed() {
        findNavController(R.id.container).run {
            when (currentDestination!!.id) {
                // タブごとの最初の画面
                R.id.tabFragment1, R.id.tabFragment2, R.id.tabFragment3 -> finish()
                // それ以外の画面
                else -> popBackStack()
            }
        }
    }
    ```

### コードで生成して配置

`NavHostFragment#create()` でインスタンスを生成して FragmentTransaction で配置。

```kotlin
supportFragmentManager
    .beginTransaction()
    .add(R.id.view_id, NavHostFragment.create(R.navigation.nav_graph_name), null)
    .commit()
```

## 遷移の実行

Navigation Editor で定義。

### NavController の取得

* Fragment#findNavController()
* Activity#findNavController(viewId: Int)
    * NavHostFragment を配置した &lt;fragment&gt; の ID を引数に指定
* View#findNavController()
    * NavHostFragment を配置した **View** の ID を引数に指定
    * コードで生成した NavHostFragment を add/replace した ViewGroup を findViewById() で取得して、それをレシーバーにするなど

### 画面 (Fragment) への遷移

1. Destinations ペインの HOST が NavHostFragment を配置した Activity/Fragment であることを確認
1. New Destinationをクリックして、任意の Fragment を選択して追加
    * 最初に追加した Fragment が自動で画面遷移の起点に設定される
        * "Assign start destination" で任意の Fragment に変更も可能
    * "Create new Destination" で Fragment の作成も可能
    * Fragment の代わりに placeholder の配置とそこへの Action も作成可能
        * placeholder への action をコードで呼び出す（後述）と例外になる
1. 遷移元 Destination を選択、右側の丸印から遷移先の Destination にドラッグして Action を作成
1. 作成した Action を使って NavController#navigate() で遷移

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67520/3bb2e986-16d5-156c-4d92-164d773e36be.png" width="400" />

### ダイアログへの遷移

1. New Destination をクリックして DialogFragment を継承した Fragment を選択
1. 配置した DialogFragment への Action を設定
    * XML をテキストで開くと **&lt;fragment&gt; ではなく &lt;dialog&gt;** で囲まれる
    * ダイアログから別の Destination への Action も作成可能
1. 作成した Action を使って NavController#navigate() で開く

### Global Action の実行

同じ Navigation graph 内からならどこからでも呼び出せる Action 。

1. 任意の Destination でコンテキストメニューを開く
1. "Add Action" > "Global" で作成（下記の短い矢印）
1. 作成した Action を使って NavController#navigate で遷移

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67520/94266979-03cf-ab1a-5fee-ed1f61c8b7c6.png" width="200" />

### 画面を戻るとき、 NavController ではなく FragmentManager からバックスタックを pop すると IllegalArgumentException が発生する

NavController で積んだバックスタックを FragmentManager で pop するのはNG。
戻った画面で再度同じアクションをすると、下記のような例外が発生する。

```
java.lang.IllegalArgumentException: navigation destination [APPLICATION-ID]:id/[ACTION-NAME] is unknown to this NavController
```

Fragment の pop は Navigation に任せるか、自分で操作したいときは NavController を使って行う。

# データの受け渡し

## サポートするデータ型

https://developer.android.com/guide/navigation/navigation-pass-data#supported_argument_types

### Destination が受け取るデータの定義

Navigation Graph で Destination を選択し、 Arguments の『＋』ボタンを押下。
Destination が受け取りたいパラメータ名とデータ型をダイアログで入力。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67520/cce2570b-e4a8-7054-befd-d20eca98e144.png" width="300" />

|項目名|意味|
|:--|:--|
|Name|パラメータ名|
|Type|データ型|
|Array|配列 or NOT|
|Nullable|NULLの許容|
|Default Value|デフォルト値（省略可）|

## Bundle を使う

1. Bundle を生成し、Key にパラメータ名を、 Value に値をセット
1. NavController#navigate() の第2引数にこの Bundle を指定して実行

* 遷移元から渡す

```kotlin
val params = bundleOf("name" to "日本太郎", "age" to 30)
findNavController().navigate(R.id.actionToDestination, params)
```

* 遷移先で受け取る

```kotlin
arguments!!.run {
    val name = getString("name")        // 日本太郎
    val age = getInt("age").toString()  // 30
}
```

## Safe Args を使う

Safe Args を導入して任意の Action を作成すると、遷移元と遷移先それぞれに、データを受渡しする為のクラスと、必要なメソッドが自動で作成される。

|Destination の種類|作成されるクラスのサフィックス|作成されるメソッド|
|:--|:--|:--|
|遷移元|Directions|アクションと同名のメソッド|
|遷移先|Args|パラメータと同名の getter|

実装例は以下の通り。

* "name" と "age" という値を受け取る NextFragment を定義
* NextFragment に遷移する StartFragment を定義
* StartFragment → NextFragment に遷移する actionFirstToNext という Action を定義
* 以下のクラスが生成され、それぞれに必要なメソッドが生成される
    * StartFragmentDirections クラス
        * actionFirstToNext(name, age) メソッド
    * NextFragmentArgs クラス
        * name ゲッタ
        * age ゲッタ

手順は以下の通り。

1. 遷移元で Directions クラスの Action と同名のメソッドを使い Action オブジェクトを生成
1. 生成した Action オブジェクトを引数にして NavController#navigate() を実行
1. デリゲートを利用して Args クラスのインスタンスを生成
1. 対応するプロパティを使ってパラメータを受け取る

* 遷移元から送る

```kotlin
findNavController().navigate(
    StartFragmentDirections.actionFirstToNext("日本太郎", 30)
)
```

* 遷移先で受け取る

```kotlin
// デリゲートで Args を作成
private val mNavArgs by navArgs<NextFragmentArgs>()

// 値を受け取る
val name = mNavArgs.name  // 日本太郎
val age = mNavArgs.age    // 30
```

# その他 Tips

## バックスタックをクリアして戻る

`app:popUpTo` と `app:popUpToInclusive="true"` を組み合わせて定義。

|要素|指定時の挙動|
|:--|:--|
|`app:popUpTo`|**対象の Fragment から上にあるバックスタックを全て破棄**して、次の Fragment を積む|
|`app:popUpToInclusive`|`app:popUpTo` と併用<br />true だと **`app:popUpTo` に指定した Fragment も含めてバックスタックを破棄**して新たに Fragment を積む<br />false か指定しない場合、指定した Fragment は残したままで Fragment を積む|

## 他の Navigation graph を利用する

&lt;include&gt; タグで、他の Navigation graph を指定

```xml
<include app:graph="@navigation/[OTHER-GRAPH-NAME]" />
```

## 画面遷移を起こせる OnClickListener を生成する

アクションの ID を引数に Navigation#createNavigateOnClickListener() を実行して OnClickListener を生成

```kotlin
// OnClickListener を生成してボタンに設定
button.setOnClickListener(
    Navigation.createNavigateOnClickListener(R.id.actionToDestination)
)
```

## ダイアログ押下時のコールバックを受け取る

DialogFragment の `parentFragmentManager.fragments.first()` で呼び出し元の Fragment を取得できる

```kotlin
// 呼び出し元が DialogListener を実装している前提
AlertDialog.Builder(requireContext())
    .setPositiveButton("OK") { _, _ ->
        val listener = parentFragmentManager.fragments.first() as? DialogListener
        listener?.onDialog()
    }
    .create()
```

### ダイアログのコールバックで NavController#navigate() するとエラー

ダイアログのコールバックを受け取る Fragment で `NavController#navigate(R.id.actionFragmentToXXXXX)` とするとエラーになる。

```
java.lang.IllegalArgumentException: navigation destination com.example.appid:id/actionFragmentToXXXXX is unknown to this NavController
```

リスナ経由で Action を呼び出すのは DialogFragment だが、呼び出そうとしてるのは Fragment の Action であって DialogFragment の Action ではない。  
ダイアログから遷移先への Action を作成して、こちらを実行すればエラーにならない。

```kotlin
AlertDialog.Builder(requireContext())
    .setPositiveButton("OK") { _, _ ->
        findNavController().navigate(R.id.actionDialogToXXXXX)
    }
    .create()
```

# 参考

* Navigation Overview  
https://developer.android.com/guide/navigation

* AndroidX Releases  
https://developer.android.com/jetpack/androidx/versions

# 更新履歴

|日時|更新内容|
|:--:|:---|
|2020.02.22|FragmentManager からバックスタックを pop したときの例外について追記|
|2020.12.07|NavHostFragment を配置する <fragment> にIDを指定しないと例外が起きることについて追記|