Navigation Architecture Componentを試してみる

この記事は[ニフティグループ Advent Calendar 2018](https://qiita.com/advent-calendar/2018/nifty)の25日目の記事です。ついに最終日です。
昨日は@omasumasuさんの「[API仕様書を作らなくてもいい！？簡単にAPIを作成する方法](https://qiita.com/omasumasu/items/2b10ef321f6b57dd6021)」でした。

# Navigation Architecture Componentとは

最近Googleが力を入れて開発しているライブラリ群、Android Architecture Components。
ライフサイクル問題を楽にするLiveDataやViewModel、データ永続化のためのRoomなど提供範囲は多岐に渡りますが、いずれも今までAndroid開発で面倒だった部分を楽にしてくれます。ニフティの提供するアプリでも徐々に採用を広げています。

そんなArchitecture Componentsの中で、画面遷移を担当するのがNavigationです。
従来のアプリ開発ではRouterを作って画面遷移を担当させるパターンが多かったかと思いますが、その代替となるか...?というライブラリです。
2018年12月現在まだAlpha版ではありますが、目立ったバグも減ってきたため、試してみようと思います。

# 注意

Navigationは

- 画面遷移の定義
- 画面間の型安全なデータ受け渡し(SafeArgs)
- 遷移時のアニメーション
- DeepLink対応

といった機能を持ちますが、本記事では画面遷移の定義のみ解説します。

本記事の内容は下記の環境を前提としています。
Alpha版のため、今後破壊的変更が入る可能性があります。

- Android Studio 3.2.1
- compileSdkVersion 28
- Kotlin Android Extension使用

```
implementation "org.jetbrains.kotlin:kotlin-stdlib:1.3.11"
implementation "com.android.support:appcompat-v7:28.0.0"
implementation "com.android.support.constraint:constraint-layout:1.1.3"
implementation "android.arch.navigation:navigation-fragment-ktx:1.0.0-alpha09"
implementation "android.arch.navigation:navigation-ui-ktx:1.0.0-alpha09"
```

Android Studio 3.2.1では画面遷移グラフをビジュアル表示するNavigation Editorがデフォルトでオフになっているため、
`Preferences -> Experimental -> Enable Navigation Editor`
からNavigation Editorを有効化しています。

本記事で作成したサンプルコードは[ここ](https://github.com/jimmysharp/NavigationSample)にあります。

# 基本的な使い方

## Navigation Graphの作成

Navigation Architecture Commponentは単一Activity上でFragmentによる遷移を実現します。遷移フローはnavigation Graphと呼ばれ、xmlで記述します。
`resフォルダ右クリック -> New -> Android Resource File`
でResource xmlを追加します。

<img width="824" alt="navigation-resource.png" src="https://qiita-image-store.s3.amazonaws.com/0/223156/d83c6c29-7365-c4ad-2b8f-6404392aa2d5.png">

xmlを開くと次のようなNavigation Editorが開きます。

<img width="1270" alt="navigation-editor.png" src="https://qiita-image-store.s3.amazonaws.com/0/223156/1b5ed1ee-71fd-cddf-a61b-d1d695b70ad5.png">

左上のボタンからFragmentを追加でき、ドラッグで遷移の矢印を追加できます。
上の状態でのxmlは以下のようになっています(idはデフォルト値から変更しています)。

```xml:navigation/main_navigation.xml
<?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/main_navigation"
            app:startDestination="@id/mainFragment">
    <fragment
            android:id="@+id/mainFragment"
            android:name="io.github.jimmysharp.navigationsample.ui.main.MainFragment"
            android:label="main_fragment"
            tools:layout="@layout/main_fragment">
        <action
                android:id="@+id/action_main_go_next1"
                app:destination="@id/nextFragment1"/>
    </fragment>

    <fragment android:id="@+id/nextFragment1"
              android:name="io.github.jimmysharp.navigationsample.ui.main.NextFragment1"
              android:label="next_fragment_1"
              tools:layout="@layout/next_fragment_1">
    </fragment>
</navigation>
```

`<navigation>`以下に`<fragment>`が並び、画面遷移はその下に`<action>`として表現されています。
navigationは単一の開始ポイントを持つ必要があり、startDestinationで指定します。
「戻る」操作をすると最終的にここに戻ってくるように画面フローを設計します。

## Activityへの設定

作成したNavigation GraphをActivityに設定します。

```xml:layout/main_activity.xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:id="@+id/container"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".MainActivity">

    <fragment
            android:id="@+id/main_nav_host"
            android:name="androidx.navigation.fragment.NavHostFragment"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:navGraph="@navigation/main_navigation"
            app:defaultNavHost="true"/>

</FrameLayout>
```

作成したNavigation Graphを元に画面遷移を制御するのがNavHostFragmentです。先ほど作成したxmlを`navGraph`に指定することで画面遷移を実現してくれます。
`defaultNavHost="true"`を指定すると、Backキーで戻れるようになります。

```kotlin:MainActivity.kt
class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.main_activity)

        val navController = findNavController(R.id.main_nav_host)
        val appBarConfiguration = AppBarConfiguration(navController.graph)
        setupActionBarWithNavController(navController, appBarConfiguration)
    }

    override fun onSupportNavigateUp()
            = findNavController(R.id.main_nav_host).navigateUp()
}
```

単に画面遷移を実現するだけであれば不要ですが、上記設定をすることでActionBarと連携してくれます。

`setUpActionBarWithNavController()`により

- 自動でUpボタン(「←」のアイコン)を表示
- xmlで指定した`label`をタイトルとして表示

するようになります。そのままではUpボタンが反応しないので、`onSupportNavigateUp()`をオーバーライドすることでUpボタンが機能するようになります。
(ただしこの機能には罠があります、後述)

ActionBar連携が不要であれば`setContentView()`だけでOKです。

## 遷移イベント

ここまで出来ればあとは遷移イベントを起こすだけです。

```kotlin:NextFragment1.kt
class NextFragment1 : Fragment() {

    companion object {
        fun newInstance() = NextFragment1()
    }

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        val view = inflater.inflate(R.layout.next_fragment_1, container, false)

        view.next_1_next_button.setOnClickListener {
            findNavController().navigate(R.id.action_next1_go_next)
        }

        return view
    }
}
```

`findNavController()`でNavHostFragmentの持っているNavControllerを取得し、`navigate(resId)`でNavigation xmlで定義したactionのIDを指定することで画面遷移します。

以上を設定すると次のような遷移が実現できます。

![navigation-simple.png](https://qiita-image-store.s3.amazonaws.com/0/223156/47a798cd-e631-18c0-b0a5-8c5273d0544f.png)

# 少し複雑な遷移

## PopUpTo

もう少し遷移を足して、
`MainFragment -> NextFragment1 -> NextFragment2`
`MainFragment -> NextFragment2`
と2通りの遷移をするようにしてみます。

<img width="669" alt="navigation-popupto.png" src="https://qiita-image-store.s3.amazonaws.com/0/223156/4e132844-a686-94da-1374-c205bb308b49.png">

ここで、どちらの経路を通っても戻るときは
`NextFragment2 -> MainFragment`
と戻れるようにすることを考えます。
つまり、NextFragment1をバックスタックに積まないようにします。

```xml:navigation/main_navigation.xml
<?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/main_navigation"
            app:startDestination="@id/mainFragment">

    <fragment
            android:id="@+id/mainFragment"
            android:name="io.github.jimmysharp.navigationsample.ui.main.MainFragment"
            android:label="main_fragment"
            tools:layout="@layout/main_fragment">
        <action
                android:id="@+id/action_main_go_next1"
                app:destination="@id/nextFragment1"/>
        <action
                android:id="@+id/action_main_go_next2"
                app:destination="@id/nextFragment2"/>
    </fragment>

    <fragment android:id="@+id/nextFragment1"
              android:name="io.github.jimmysharp.navigationsample.ui.main.NextFragment1"
              android:label="next_fragment_1"
              tools:layout="@layout/next_fragment_1">
        <action
                android:id="@+id/action_next1_go_next"
                app:destination="@id/nextFragment2"
                app:popUpTo="@id/mainFragment"
                app:popUpToInclusive="false"/>
    </fragment>
    <fragment
            android:id="@+id/nextFragment2"
            android:name="io.github.jimmysharp.navigationsample.ui.main.NextFragment2"
            android:label="next_fragment_2"
            tools:layout="@layout/next_fragment_2">
    </fragment>
</navigation>
```

NextFragment1からの遷移に`papp:popUpTo="@id/mainFragment"`と`app:popUpToInclusive="false"`を指定しています。
`popUpToInclusive`がfalseの時、`popUpTo`は画面遷移前に、指定したFragmentより上にあるFragmentをpopするオプションになります。
上の例ではMainFragmentの上にあったNextFragment1がバックスタックに積まれずにNextFragment2へ遷移します。

## Global Action

どの画面からもMainFragmentに一気に戻りたい場合はどうすればいいでしょうか。
直接線を引っ張っても良いのですが、画面が増えた場合に遷移グラフが複雑になりそうです。

どこからでも利用する遷移は`Global Action`として定義できます。

```xml:navigation/main_navigation.xml
<navigation ... >
    <action
            android:id="@+id/action_global_go_main"
            app:destination="@id/mainFragment"
            app:popUpTo="@id/main_navigation"
            app:popUpToInclusive="true"/>
</navigation>
```

`action`の定義が`fragment`の外にあり、文字通りグローバルに使用できるようになっています。乱用すると遷移グラフを定義した意味がなくなるので、ルート画面への強制遷移のような頻出パターンのみに限定して使うと良いかと思います。

この場合、バックスタックを消さないと
`MainFragment -> NextFragment1 -> NextFragment2 -> MainFragment`
のようなスタックになり、BackキーでMainFragmentからNextFragment2に戻れてしまいます。
`popUpToInclusive="true"`の時、`popUpTo`はバックスタックを全てpopする挙動になるため、これを指定しています。この際指定するIDはFragmentではなく、NavigationのIDになります。

## Conditional Navigation

未ログイン時のみログイン用の画面に飛ばすなど、特定条件下で別の画面遷移に飛ばしたい、という場面があります。これを公式では[Conditional Navigation](https://developer.android.com/topic/libraries/architecture/navigation/navigation-conditional)と呼んでいるのですが、簡単な解説のみで具体的な実装方法が載っていません。
これは以下のように実現できます。

<img width="719" alt="navigation-conditional.png" src="https://qiita-image-store.s3.amazonaws.com/0/223156/6233b77a-fbed-9955-0561-3774897e4236.png">

```xml:navigation/main_navigation.xml
<navigation
            android:id="@+id/main_navigation"
            ...>
    <fragment
            android:id="@+id/mainFragment"
            ...>
        ...
        <action
                android:id="@+id/action_main_go_login"
                app:destination="@+id/login_navigation"
                app:popUpTo="@+id/main_navigation"
                app:popUpToInclusive="true"/>
    </fragment>
    ...
    <navigation
            android:id="@+id/login_navigation"
            app:startDestination="@id/loginFragment">

        <fragment android:id="@+id/loginFragment"
                  android:name="io.github.jimmysharp.navigationsample.ui.main.LoginFragment"
                  android:label="login_fragment"
                  tools:layout="@layout/login_fragment">
            <action
                    android:id="@+id/action_login_go_back"
                    app:destination="@+id/main_navigation"
                    app:popUpTo="@id/login_navigation"
                    app:popUpToInclusive="true"/>
        </fragment>
    </navigation>
</navigation>
```
`<navigation>`の中にはネストして`<navigation>`を定義できます(Nested Graph)。`destination`にNavigationのIDを指定することで独立したNavigation Graphに飛ばすことができます。
Backキーで戻られると困るので、Navigation間の遷移時には`popUpTo`と`popUpToInclusive`でスタックをクリアします。

ここで1つ罠があり、このままだとLoginFragmentにUpボタンが出てしまいます。

<img width="250" alt="navigation-login-up.png" src="https://qiita-image-store.s3.amazonaws.com/0/223156/65e5c965-1347-4388-366d-06b447f13f5c.png">

これは**バックスタックの状態を見ず、Navigation Graphのみを参照する**ために起こります。startDestination以外の全てでUpボタンを出すようになっており、[このあたりのissue](https://issuetracker.google.com/issues/111907342)を見る限り仕様のようです。
幸いalpha07からこの問題を回避することができるようになりました。

```kotlin:MainActivity.kt
//val appBarConfiguration = AppBarConfiguration(navController.graph)
val appBarConfiguration = AppBarConfiguration(setOf(R.id.mainFragment, R.id.loginFragment))
```

AppBarConfigurationにGraphを与える代わりに、FragmentのIDを列挙します。ここで指定したFragmentにはUp Buttonが出ないようになります。

# ネストしたFragment

画面構成によっては、Fragment内にさらに小さなFragmentを作り、2段階の遷移が発生することがあります。
この場合も単純にネストさせれば良い...のですが、一部挙動に罠があります。

##### 外側のNavigation

<img width="613" alt="navigation-layered.png" src="https://qiita-image-store.s3.amazonaws.com/0/223156/e08426fc-d0a5-c6bd-c4e1-994c8dd8df4d.png">

##### 内側のNavigation

<img width="454" alt="navigation-inside.png" src="https://qiita-image-store.s3.amazonaws.com/0/223156/969f5b0e-d129-6d30-c40f-89c6063d2456.png">


```xml:layout/layered_fragment.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:id="@+id/container"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        tools:context=".ui.main.LayeredFragment">

    <TextView
            android:id="@+id/layered_message"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="LayeredFragment"
            android:background="@color/colorAccent"
            android:gravity="center"/>

    <fragment
            android:id="@+id/inside_nav_host"
            android:name="androidx.navigation.fragment.NavHostFragment"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:navGraph="@navigation/inside_navigation"
            app:defaultNavHost="true"/>

</LinearLayout>
```

LayeredFragmentの中でさらにNavHostFragmentを用意して、

```
Activity
  - NavHostFragment(main_navigation)
    - LayeredFragment
      - NavHostFragment(inner_navigation)
        - InsideFragment1,2
```

という階層構造を作っています。この時の挙動はどうなるでしょうか。

### NavController

`findNavController()`を呼ぶと、最も近い親のNavHostFragmentが持つNavControllerが取得されます。つまり、

```
LayeredFragment1 -> NavHostFragment(main_navigation)
InsideFragment1 -> NavHostFragment(inner_navigation)
```

を操作することになります。InsideFragment1ではinner_navigationに基づく遷移を呼び出せますが、main_navigationレベルでの遷移はできません。

### Back Key

![navigation-back.png](https://qiita-image-store.s3.amazonaws.com/0/223156/c35a3c2f-01a8-4a24-fb93-da0f21ca39f7.png)

Back Keyを押した場合は内側の遷移が優先され、その後に外側という順序になるようです。
戻る操作として期待される順序になっています。

### Action Bar

![navigation-up.png](https://qiita-image-store.s3.amazonaws.com/0/223156/6d7c4f84-0803-f9b9-be71-37de27bf8161.png)

Up Buttonの場合はどうなるかというと、Action Barはmain_navigationに基づいて戻り、FragmentはBack Key同様にinner_navigationに基づいて戻ってしまいました。結果、ありえないはずの画面ができてしまっています。

将来的に修正されるかもしれませんが、現状は以下のような対策を取るしかないかと思います。

- 内側のNavHostFragmentでは`defaultNavHost`を削除し、戻る操作を諦める
- `setupActionBarWithNavController()`を削除し、ActionBar連携を諦める
- Fragmentのネストを諦めて、外側の遷移をActivity遷移に変える

＊2019/4/16追記:
Up Buttonの挙動についての解説を修正しました。
また本挙動はバージョン1.0.0では修正されており、上位のmain_navigationに基づく遷移に統一されました。

# まとめ

今までFragmentでの画面遷移はFragmentTransactionで行ってきましたが、自分でバックスタックを管理しなくてよくなりその点ではだいぶ楽になった印象です。
一方でActionBar連携がイケてなかったりと問題含みではあるので、stableになるまでに改善されないかな、と淡い期待を抱いています。