Help us understand the problem. What is going on with this article?

Navigation Architecture Componentを試してみる

More than 1 year has passed since last update.

この記事はニフティグループ Advent Calendar 2018の25日目の記事です。ついに最終日です。
昨日は@omasumasuさんの「API仕様書を作らなくてもいい!?簡単にAPIを作成する方法」でした。

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を有効化しています。

本記事で作成したサンプルコードはここにあります。

基本的な使い方

Navigation Graphの作成

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

navigation-resource.png

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

navigation-editor.png

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

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に設定します。

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キーで戻れるようになります。

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です。

遷移イベント

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

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

少し複雑な遷移

PopUpTo

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

navigation-popupto.png

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

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として定義できます。

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と呼んでいるのですが、簡単な解説のみで具体的な実装方法が載っていません。
これは以下のように実現できます。

navigation-conditional.png

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間の遷移時にはpopUpTopopUpToInclusiveでスタックをクリアします。

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

navigation-login-up.png

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

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

navigation-layered.png

内側のNavigation

navigation-inside.png

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

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

Action Bar

navigation-up.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になるまでに改善されないかな、と淡い期待を抱いています。

jimmysharp
ストレージの保守しながらAndroidアプリとか書いてます
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away