Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

Navigation で DialogFragment を表示した話

こんにちは、Studyplus Androidチームの中島です。

2019年6月より、Studyplus AndroidアプリはTarget SDK28への対応、合わせてAndroidXへの移行を行いました。

今まで「AndroidXに対応したらここ直しましょう」としていたところをガシガシ直していくのは楽しかったです。

閑話休題、今回のブログではAACのNavigationで DialogFragment を表示した話をしたいと思います。

developer.android.com

はじめに

はっきり言っておきますが、この 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>

developer.android.com

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の画面遷移を視覚的にデザインできるようにした、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主催の勉強会資料もとても参考になります。

speakerdeck.com

本題です。

前述の通り、Navigation2.0.0ではDialogFragmentに対応していないので対処法を考えなければいけません。 調査の結果、STAR-ZEROさんのブログ: Navigation + DialogFragmentを拝見し、参考にさせていただきました。

medium.com

結論として、やることは以下のような形になります。

  1. DialogFragment専用のCustomNavigatorを実装する
  2. CustomNavigatorで指定したhtmlタグを使い、navGraph内にDialogFragmentを定義する
  3. xml内で app:navGraph attributeを定義しないようにする
  4. NavHostFragmentを持つActivityのコードでNavigationProviderにCustomNavigatorを追加する
  5. 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>

変更点について、実装順に説明していきます。

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)はないよ」ということでクラッシュしていたようです。

これについてはNavControllerOnDestinationChangedListenerを設定してみるとわかります。 バックキーで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にpopUpTopopUpToInclusiveを設定する

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:popUpToInclusivetrueをそれぞれ指定することで、自分の一つ前まで戻す指定としました。

ここまで実装してようやく期待通りの結果が得られました。

まとめ

Navigationは、Android開発で厄介なものの一つだったFragment同士の遷移を、直感的かつ安全に実装できるライブラリです。 まだリリースされて比較的日が浅いせいか、LiveDataやViewModelに比べてサンプルケースが少なく手を出しづらいかもしれません。 ですが、実際に触ってみるととてもわかりやすく便利なものだということが実感できました。 BottomNavigationViewなどにも簡単に接続できますので、Fragment遷移を行う際に試してみてはいかがでしょうか。

今回の話は冒頭でも述べたように長く使えるものではありませんでしたが、Navigationの理解が深められたと思えばなかなか有意義であったと思っています。 DialogFragmentがサポートされた以上、CustomNavigatorを作成する機会はあまりないかもしれませんが、今後にもこの知見を役立てていければと思っています。