この記事はニフティグループ 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を追加します。
xmlを開くと次のようなNavigation Editorが開きます。
左上のボタンからFragmentを追加でき、ドラッグで遷移の矢印を追加できます。
上の状態でのxmlは以下のようになっています(idはデフォルト値から変更しています)。
<?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 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キーで戻れるようになります。
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です。
遷移イベント
ここまで出来ればあとは遷移イベントを起こすだけです。
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を指定することで画面遷移します。
以上を設定すると次のような遷移が実現できます。
少し複雑な遷移
PopUpTo
もう少し遷移を足して、
MainFragment -> NextFragment1 -> NextFragment2
MainFragment -> NextFragment2
と2通りの遷移をするようにしてみます。
ここで、どちらの経路を通っても戻るときは
NextFragment2 -> MainFragment
と戻れるようにすることを考えます。
つまり、NextFragment1をバックスタックに積まないようにします。
<?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 ... >
<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
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ボタンが出てしまいます。
これはバックスタックの状態を見ず、Navigation Graphのみを参照するために起こります。startDestination以外の全てでUpボタンを出すようになっており、このあたりのissueを見る限り仕様のようです。
幸いalpha07からこの問題を回避することができるようになりました。
//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
<?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
Back Keyを押した場合は内側の遷移が優先され、その後に外側という順序になるようです。
戻る操作として期待される順序になっています。
Action Bar
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になるまでに改善されないかな、と淡い期待を抱いています。