Android JetpackのNavigationを試して導入手順をまとめた


Google IO 2018で発表されたJetpackの新しいコンポーネントのNavigationをアプリに導入してみた。

The Navigation Architecture Component  |  Android Developers

TL;DR

モチベーション

このエントリを通してNavigationを実践投入することがモチベーション。ToolbarBottom NavigationonClickイベントに関わる画面遷移をNavigationでどのように実現するのかをコードと共にまとめていく。

gradle settingにnavigationを追加する

build.gradleに依存を追加していく。

1
2
3
4
    implementation "android.arch.navigation:navigation-fragment:1.0.0-alpha01"
    implementation "android.arch.navigation:navigation-ui:1.0.0-alpha01"
    implementation "android.arch.navigation:navigation-fragment-ktx:1.0.0-alpha01"
    implementation "android.arch.navigation:navigation-ui-ktx:1.0.0-alpha01"

これでNavigationを使える準備はOK。

※ Navigation Editorを使うにはAndroid Studio 3.2が必要

このエントリの画面遷移は単純でHome画面、天気予報一覧画面と詳細画面の3つとToolbarのメニューから選択ができる設定画面がある。
resにnavigationフォルダを追加すると新規作成メニューから Navigation Resource Fileが選択できる。

Navigation EditorをつかってFragment、Activityの遷移を定義した。Fragmentの遷移は天気予報一覧(forecastsFragment)から詳細画面(forecastFragment)に矢印が結ばれていることで表現されている。homeFragmentやsettingsActivityはToolbarやBottomNavigationからの遷移があるためEditorで追加をした。
フラグメント間の遷移のtransitionはEditorから定義できる。今回のエントリでは詳細に使っていないので内容については割愛。

homeFragmentは開始位置として定義しているのでhomeアイコンが表示されている。

Editorからも遷移を定義できるがxmlからも修正ができる。xmlは次のように定義されている。xmlファイルは nav_graph.xmlとして保存している。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<!--?-->xml version="1.0" encoding="utf-8"?>
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:animateLayoutChanges="false"
          app:startDestination="@id/homeFragment"
    >

    
        android:id="@+id/homeFragment"
        android:name="me.soushin.sunshine.ui.home.HomeFragment"
        android:label="HomeFragment"
        />

    
        android:id="@+id/forecastsFragment"
        android:name="me.soushin.sunshine.ui.forecast.ForecastsFragment"
        android:label="ForecastsFragment"
        >
        
            android:id="@+id/toForecast"
            app:destination="@id/forecastFragment"
            />
    

    
        android:id="@+id/forecastFragment"
        android:name="me.soushin.sunshine.ui.forecast.ForecastFragment"
        android:label="ForecastFragment"
        >
        
            android:name="forecast"
            app:type="string"
            />
    

    
        android:id="@+id/settingsActivity"
        android:name="me.soushin.sunshine.ui.settings.SettingsActivity"
        android:label="activity_settings"
        tools:layout="@layout/activity_settings"
        />

MainActivityのfragmentエリアを修正する

Navigation導入前にfragmentの表示・切り替えを定義していたlayoutを次のようにfragmentタグを使い変更する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<!--?-->xml version="1.0" encoding="utf-8"?>
.support.design.widget.CoordinatorLayout
    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:layout_width="match_parent"
    android:layout_height="match_parent"
    >

    
        android:id="@+id/navHostFragment"
        android:name="androidx.navigation.fragment.NavHostFragment" ★
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_marginTop="48dp"
        android:layout_marginBottom="56dp"
        app:defaultNavHost="true" ★
        app:navGraph="@navigation/nav_graph" ★
        />
.support.design.widget.CoordinatorLayout>

android:nameを androidx.navigation.fragment.NavHostFragmentとすることでNavigation機能のターゲットとなる。app:defaultNavHostをtrueに設定するとBackボタンやUpボタンが連携される。app:navGraphはNavigation Editorで定義した nav_graphを指定している。


ここまででNavigationをコードから操作できる準備が整った。ここから各ナビゲーションエリアの遷移をコードで実現していく。

ToolbarとNavigaitonを連携させる

ToolbarとNavigaitonを連携させるには onOptionsItemSelectedonSupportNavigateUpを次のようにオーバーライドする。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class MainActivity : AbstractActivity() {

    val navController: NavController by lazy { findNavController(R.id.navHostFragment) }

    override fun onOptionsItemSelected(item: MenuItem): Boolean {
        return NavigationUI.onNavDestinationSelected(item, navController) || super.onOptionsItemSelected(item)
    }

    override fun onSupportNavigateUp() = navController.navigateUp()
}

NavigationnUIにActionBarとNavcontrollerを渡すために setupActionBarWithNavControllerをonCreate内でコールする。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class MainActivity : AbstractActivity() {

    val navController: NavController by lazy { findNavController(R.id.navHostFragment) }

    override fun onCreate(savedInstanceState: Bundle?) {

        findViewById&lt;Toolbar>(R.id.toolbar).also {
            setSupportActionBar(it)
            setupActionBarWithNavController(navController)
        }
    }
}

注意点としてNavigationが参照するメニューのIDとToolbarのmenu.xmlで定義するIDを一致させる必要がある。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# menu_main.xml

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"
    tools:context="me.soushin.sunshine.app.MainActivity">
    
        android:id="@+id/settingsActivity" ★
        android:orderInCategory="100"
        android:title="@string/action_settings"
        app:showAsAction="never" />


---

# nav_graph.xml

<!--?-->xml version="1.0" encoding="utf-8"?>
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:animateLayoutChanges="false"
          app:startDestination="@id/homeFragment"
    >

    
        android:id="@+id/settingsActivity" ★
        android:name="me.soushin.sunshine.ui.settings.SettingsActivity"
        android:label="activity_settings"
        tools:layout="@layout/activity_settings"
        />

settingsActivityでidを一致させている。これでtoolbarの遷移がNavigationに切り替わった。

BottomNavigationとNavigaitonを連携させる

BottomNavigationもToolbarと同様にmenu xmlとnav_graph xmlのIDを一致させる必要がある。詳しくは後述するgithubのコードを参照して頂きたい。

Navigationと連携したコードはToolbarと同様に簡略化できる。次のようなコードになる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class MainActivity : AbstractActivity() {

    val navController: NavController by lazy { findNavController(R.id.navHostFragment) }

    override fun onCreate(savedInstanceState: Bundle?) {

        findViewById&lt;BottomNavigationView>(R.id.bottomNavigation)?.apply {
            setupWithNavController(navController)
        }
    }
}

ボトムナビゲーションの切り替え(タブ切り替え)にアニメーションが入ってしまう

navigation-ui-ktxで用意されている setupWithNavControllerを使うとボトムナビゲーションで選択したFragmentに切り替わるときにアニメーションが入ってしまう。Fragment間の遷移ではnavigation editorを使いtransitionを定義できるがボトムナビゲーションの切り替えのアニメーションは決め打ちでアニメーションが定義されてしまっている。

アニメーションが定義されているNavigationUIクラスのコードは次のようになっていた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public class NavigationUI {

    private static boolean onNavDestinationSelected(@NonNull MenuItem item,
            @NonNull NavController navController, boolean popUp) {
        NavOptions.Builder builder = new NavOptions.Builder()
                .setLaunchSingleTop(true)
                .setEnterAnim(R.anim.nav_default_enter_anim)
                .setExitAnim(R.anim.nav_default_exit_anim)
                .setPopEnterAnim(R.anim.nav_default_pop_enter_anim)
                .setPopExitAnim(R.anim.nav_default_pop_exit_anim);

        // 
    }
}

NavigationUIを継承したり、このアニメーションを潰すスマートなやり方が見つからなかったので setupWithNavControllerを独自で実装した。独自というよりはアニメーションを呼び出している部分をカットしただけのBottomNNavigationViewの拡張関数である。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
fun BottomNavigationView.setupWithNavController(navController: NavController) {
    this.setOnNavigationItemSelectedListener { item ->
        try {
            navController.navigate(item.getItemId(), null, NavOptions.Builder().build())
            true
        } catch (e: IllegalArgumentException) {
            false
        }
    }

    navController.addOnNavigatedListener { controller, destination ->
        val destinationId = destination.id
        val menu = this.getMenu()
        var h = 0
        val size = menu.size()
        while (h &lt; size) {
            val item = menu.getItem(h)
            if (item.getItemId() == destinationId) {
                item.setChecked(true)
            }
            h++
        }
    }
}

Fragment間の遷移にNavigaitonを連携させる

1
2
3
4
5
6
7
val onClick: (Forecast) -> Unit = { forecast: Forecast ->
    Bundle().apply {
        putParcelable(ForecastFragment.KEY_FORECAST, forecast)
    }.let {
        view.findNavController().navigate(R.id.toForecast, it) ★
    }
}

NavController#navigateを呼び出せばNavigationと連携できる。第一引数のresIdにはnavigation editorで定義したactionのidを指定する。第二引数にはFragmentへ渡すargumentsを指定する。

このコードではresIdに誤りがある場合、予期しないFragment遷移となってしまう。またFragmentに渡すargumentsが本当に期待するものかは定かではない。ここでsafe-argsを使う出番である。

safe-argsを有効にする

Rootのbuild.gradleのdependencies/class_pathにsafe-argsを追加する

1
classpath "android.arch.navigation:navigation-safe-args-gradle-plugin:$navVersion"

モジュールのbuild.gradleにpluginを追加する

1
apply plugin: 'androidx.navigation.safeargs'

これでsafe-argsが有効になったのでプロジェクトをリビルドする。
ビルドが成功すると{from-Fragment}Directionsクラスと{to-Fragment}Argsクラスが生成される。生成されたクラスをナビゲーション遷移に使っていく。

{from-Fragment}Directionsクラスと{to-Fragment}Argsクラスを使う

遷移元のコードは次のようになる。

1
2
3
val onClick: (Forecast) -> Unit = { forecast: Forecast ->
    view.findNavController().navigate(ForecastsFragmentDirections.toForecast(gson.toJson(forecast)))
}

ForecastsFragmentDirectionsクラスを使い、safe-argsで生成されたtoForecastメソッドをコールしている。また引数にはnavigation editorで定義したargumentsを渡す。toForecastメソッドはnav_graph.xmlで定義したaction idが参照されている。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# nav_graph.xml

   >

    
        android:id="@+id/forecastsFragment"
        android:name="me.soushin.sunshine.ui.forecast.ForecastsFragment"
        android:label="ForecastsFragment"
        >
        
            android:id="@+id/toForecast" ★
            app:destination="@id/forecastFragment"
            />
    

    
        android:id="@+id/forecastFragment"
        android:name="me.soushin.sunshine.ui.forecast.ForecastFragment"
        android:label="ForecastFragment"
        >
        
            android:name="forecast"  ★
            app:type="string"
            />
    

遷移先は次のようなコードになる。

1
2
3
4
5
6
7
8
class ForecastFragment : AutoDisposeFragmentKotlin() {

    private val forecast: Forecast by lazy {
        ForecastFragmentArgs.fromBundle(arguments).let {
            gson.fromJson(it.forecast, Forecast::class.java)
        }
    }
}

ForecastFragmentArgsクラスを使い遷移元から渡されたargumentsを参照できる。

safe-argsはNavigation定義から遷移元、遷移先に必要なクラスとメソッドを用意してくれる。これによりNavigation定義どおりの実装が可能になるしコード品質も格段に向上できる。

まとめ

コード

これまでの紹介では断片的なコードとなっているためNavigationを有効にしたプルリクエストを残しています。参照いただき参考になれば幸いです。

Feature/add navigation by soushin · Pull Request #11 · soushin/sunshine-app · GitHub