こんにちは、Studyplus Androidチームの中島です。
2019年6月より、Studyplus AndroidアプリはTarget SDK28への対応、合わせてAndroidXへの移行を行いました。
今まで「AndroidXに対応したらここ直しましょう」としていたところをガシガシ直していくのは楽しかったです。
閑話休題、今回のブログではAACのNavigationで DialogFragment を表示した話をしたいと思います。
はじめに
はっきり言っておきますが、この DialogFragment の表示方は賞味期限が短いです。 なぜかと言いますと、DialogFragment は Navigation 2.1.0-alpha03 から公式にサポートされており、2.1.0安定板が公開された時に意味をなくすからです。
公式より抜粋
<dialog android:id="@+id/my_dialog_fragment" android:name="androidx.navigation.myapp.MyDialogFragment"> <argument android:name="myarg" android:defaultValue="@null" /> <action android:id="@+id/myaction" app:destination="@+id/another_destination"/> </dialog>
Studyplus Androidチームでは一応beta以上になってから採用しようという話をしていたことから、 コーディング当時(7/12)alpha06だった2.1.0の採用を見送っておりました。
(ちなみに、7/17に2.1.0-beta01、7/19に2.1.0-beta02がリリースされております…)
リファクタリング対応中のクラス群にDialogFragmentがあったのですが、他Fragmentとのデータのやり取りがかなり煩雑な状態でした。 そのため、ActivityレベルのViewModelでデータを保持しつつ、Navigationによる遷移に組み込んでしまいたいなと思ったのが今回の発端です。
Navigation によるFragment遷移の基本
NavigationはFragmentの画面遷移を視覚的にデザインできるようにした、AACの一機能です。
この節では基本となる実装を簡単に説明します。
Navigationによる画面遷移はxmlで指定します。
(この<navigation />
タグでくくったxmlのことを以下navGraphとします)
GUIでいじれるのでかなりやりやすいように感じました。
<?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" android:id="@+id/navigation_graph" app:startDestination="@id/homeFragment"> <fragment android:id="@+id/homeFragment" android:name="sample.navigation.app.HomeFragment" android:label="HomeFragment" > <action android:id="@+id/action_homeFragment_to_secondFragment" app:destination="@id/secondFragment" /> </fragment> <fragment android:id="@+id/secondFragment" android:name="sample.navigation.app.SecondFragment" android:label="SecondFragment" /> </navigation>
次に遷移を実装したいActivityにnav_host_fragment
を配置して、app:navGraphのattributeにnavGraphのidを指定します。
<!-- activity_home.xml --> <fragment android:id="@+id/nav_host_fragment" android:name="androidx.navigation.fragment.NavHostFragment" android:layout_width="match_parent" android:layout_height="match_parent" app:defaultNavHost="true" app:navGraph="@navigation/navigation_graph" />
あとはHomeFragment
からSecondFragment
に遷移させたいところで、actionを実行させるコードを書けばOKです。
// HomeFragment.kt
findNavController().navigate(HomeFragmentDirections.actionHomeFragmentToSecondFragment())
ここでは説明しませんがAnimationを付与する、Argumentを渡す、DeepLinkによるFragment指定遷移なども可能です。
詳しいことは公式をご参照ください。
また、7/18に行われたGDC Tokyo主催の勉強会の資料もとても参考になります。
Navigation によるDialogFragment遷移
本題です。
前述の通り、Navigation2.0.0ではDialogFragmentに対応していないので対処法を考えなければいけません。 調査の結果、STAR-ZEROさんのブログ: Navigation + DialogFragmentを拝見し、参考にさせていただきました。
結論として、やることは以下のような形になります。
- DialogFragment専用のCustomNavigatorを実装する
- CustomNavigatorで指定したhtmlタグを使い、navGraph内にDialogFragmentを定義する
- xml内で
app:navGraph
attributeを定義しないようにする - NavHostFragmentを持つActivityのコードでNavigationProviderにCustomNavigatorを追加する
- CustomNavigatorの追加後、NavHostFragmentにnavGraphをセットする
基本的にはその通りにやればいけるはずだと思ったのですが、STAR-ZEROさんの記事ではNavigationのバージョンが1.0.0-alpha06
だったため、一部変更が必要でした。
その変更が必要だった部分、CustomNavigatorについて詳しく説明したいと思います。
CustomNavigatorの実装
Navigator
とはNavigationにおけるactionの内容を実装するabstract classです。
@Navigator.Name("tag")
で指定したタグを使ってnavGraphにFragmentを定義することにより、そのNavigator
を使って遷移させることができます。
DialogFragment専用のCustomNavigatorは、STAR-ZEROさんのものを基にして実装した結果、以下のようになりました。
@Navigator.Name("dialog-fragment") class DialogFragmentNavigator( private val context: Context, private val manager: FragmentManager ) : Navigator<DialogFragmentNavigator.DialogDestination>() { override fun navigate( destination: DialogDestination, args: Bundle?, navOptions: NavOptions?, navigatorExtras: Extras? ): NavDestination? { val fragment = destination.createFragment(args) fragment.show(manager, TAG) // 注1. dispatchOnNavigatorNavigatedというAPIが削除されていたこと、返り値に NavDestination が追加されていたので変更 return destination } override fun createDestination() = DialogDestination(this) // 注2. Dialogを閉じる処理を呼び出すように変更 override fun popBackStack(): Boolean { val existingFragment = manager .findFragmentByTag(TAG) if (existingFragment != null) { (existingFragment as DialogFragment).dismiss() } return true } class DialogDestination(navigator: DialogFragmentNavigator) : NavDestination(navigator) { // 変更なしのため省略 } companion object { private const val TAG = "navigation_dialog" } }
navGragh 内で <dialog-fragment />
タグを使って定義することでこのCustomNavigatorが使われます。
<dialog-fragment android:id="@+id/MyDialogFragment" android:name="sample.navigation.app.MyDialogFragment" android:label="MyDialogFragment"> </dialog-fragment>
変更点について、実装順に説明していきます。
navigate()
メソッドでFragmentの表示方法を指定する
DialogFragmentなので、(DialogDestinationクラスで作成したインスタンスを)show()
メソッドで表示します。
2.0.0では一部APIが削除されていたこと、返り値としてNavDestination
が追加されていたのでその部分を変更しています( 注1
)。
DialogFragmentの表示まではこれでできましたが、ここで問題が起きました。 バックキーでDialogFragmentを閉じてもう一度開こうとした場合にクラッシュしてしまったのです。
java.lang.IllegalArgumentException: navigation destination {actionのid} is unknown to this NavController
調査してみたところ、DialogFragmentをバックキーで閉じた場合は、Navigation内部でFragmentの切り替わりが認識されていませんでした。 その結果「DialogFragmentにそんなaction(dialogを開くaction)はないよ」ということでクラッシュしていたようです。
これについてはNavController
にOnDestinationChangedListener
を設定してみるとわかります。
バックキーでDialogFragmentを閉じても、以下のリスナーが呼ばれません。
navController.addOnDestinationChangedListener { controller, destination, arguments -> // destination(*Navigation*に設定されたFragment)が切り替わった時に呼ばれるリスナー }
popBackStack()
でバックキーを押した時の挙動を指定する
ではどうするのかということで色々調べたのですが解決策が見つからず、結局Navigation2.1.0-alpha06の実装コードを確認しました…
// androidx.navigation.fragment.DialogFragmentNavigator @Navigator.Name("dialog") public final class DialogFragmentNavigator extends Navigator<DialogFragmentNavigator.Destination> { // ~~~~~~~~ @Override public boolean popBackStack() { if (mDialogCount == 0) { return false; } if (mFragmentManager.isStateSaved()) { Log.i(TAG, "Ignoring popBackStack() call: FragmentManager has already" + " saved its state"); return false; } Fragment existingFragment = mFragmentManager .findFragmentByTag(DIALOG_TAG + --mDialogCount); if (existingFragment != null) { existingFragment.getLifecycle().removeObserver(mObserver); ((DialogFragment) existingFragment).dismiss(); } return true; } // ~~~~~~~~ }
「なるほど、dismiss()をここで呼ぶんだな!」 ということで 注2
の部分を実装するに至りました。
(画面回転や、開かれている個数のカウントなどの処理もありましたが仕様上必要ないと判断しオミットしています)
これで実装は完了したと思いましたが、再び同じクラッシュが起きてしまいました…
java.lang.IllegalArgumentException: navigation destination {actionのid} is unknown to this NavController
DialogFragmentでdismiss時にNavigationのnavigateUp()を実行する
NavControllerの方の popBackStack()
などにもやはり変更があったことを確認したのですが、こちらまでカスタマイズとなると現実的ではありません。
要はバックキーによるDialogFragmentのcancel
動作と、Navigation内部の階層を戻す
動作が連携していないのが原因であると考え、以下のように実装しました。
// MyDialogFragment override fun onCancel(dialog: DialogInterface) { super.onCancel(dialog) try { findNavController().navigateUp() } catch (e: IllegalStateException) { // NavHostFragment内以外で呼ぶとthrowされるので汎用的なDialogFragmentでは避けた方が無難 } }
navigateUp()
はNavigation内の階層を一つ戻る挙動をさせるメソッドなので、onCancel()
で呼び出すことにより連携させられると考えた結果でした。
(結果的にこの処理があれば 注2
の部分がなくても挙動としては正しくなったのですが、公式のコードで行われていた処理であることを考慮して残しています)
しかしまだ終わりではありません。
DialogFragmentからの遷移actionにpopUpTo
とpopUpToInclusive
を設定する
DialogFragmentから次のFragmentに遷移した場合、その後にバックキーを押したら通常はDialogFragmentを開いたFragment
に戻るかと思います。
つまり、DialogFragmentを含めて階層が二つ戻っていることになります。
しかし、今のままだとNavigation内部では階層が一つしか戻っていないため、齟齬が生じてしまっています。
これを回避するためには、DialogFragmentのactionに 戻る場合は自分を開いたFragmentまで戻す
指定をしなければいけません。
<dialog-fragment android:id="@+id/MyDialogFragment" android:name="sample.navigation.app.MyDialogFragment" android:label="MyDialogFragment"> <action android:id="@+id/action_to_second" app:destination="@id/SecondFragment" app:popUpTo="@id/MyDialogFragment" app:popUpToInclusive="true" /> </dialog-fragment>
app:popUpTo
attributeは、このactionの後に戻った場合どこまで戻すかを指定できます。
また、app:popUpToInclusive
attributeは、true
にすることでapp:popUpTo
に指定したFragmentの一つ前まで戻すことができます。
app:popUpTo
にDialogFragment自身を、app:popUpToInclusive
にtrue
をそれぞれ指定することで、自分の一つ前まで戻す指定としました。
ここまで実装してようやく期待通りの結果が得られました。
まとめ
Navigationは、Android開発で厄介なものの一つだったFragment同士の遷移を、直感的かつ安全に実装できるライブラリです。 まだリリースされて比較的日が浅いせいか、LiveDataやViewModelに比べてサンプルケースが少なく手を出しづらいかもしれません。 ですが、実際に触ってみるととてもわかりやすく便利なものだということが実感できました。 BottomNavigationViewなどにも簡単に接続できますので、Fragment遷移を行う際に試してみてはいかがでしょうか。
今回の話は冒頭でも述べたように長く使えるものではありませんでしたが、Navigationの理解が深められたと思えばなかなか有意義であったと思っています。 DialogFragmentがサポートされた以上、CustomNavigatorを作成する機会はあまりないかもしれませんが、今後にもこの知見を役立てていければと思っています。