Navigationでバックキーをハンドリングする


はじめに

Android JetpackのNavigationを使った画面遷移における、バックキーのハンドリングについてまとめました。
具体的には、編集画面などでバックキーを押下した際に、「編集を破棄してもよろしいですか?」ダイアログを出したいときなどを想定しています。こんなイメージです。

そもそもこの仕様が良いかどうかという議論はあるかもしれませんが、それはここでは議論しません。

サンプルプロジェクト

サンプルプロジェクトとしてGoogle I/O 2018のcodelabを使って説明します。
https://codelabs.developers.google.com/codelabs/android-navigation/#0

構成は、Single Activityで、その上にFragment(NavHostFragment)が乗っています。そのNavHostFragmentの上をNavigation経由でFragmentで画面遷移するようなとてもシンプルな作りです。

実装方法

interfaceの用意

バックキーを押された際にFragmentがバックキーイベントを受け取る口としてinterfaceを用意します

OnBackPressHandler.kt
interface OnBackPressHandler {
    fun onBackPressed(): Boolean
}

Activityでのイベント受け取り

次にバックキーイベントの最初の受け取り口であるActivity(MainActivity)でイベントを受け取ります。

MainActivity.kt
override fun onBackPressed() {
    // Navigationを使っている画面のRootに相当するNavHostFragmentの取得
    val navHost = supportFragmentManager.findFragmentById(R.id.my_nav_host_fragment) as NavHostFragment

    // fragmentsにはNavHostFragmentにaddされた現在表示中の画面(Fragment)と、UIを持たないFragmentNavigator#StateFragmentだけが存在します
    val target = navHost.childFragmentManager.findFragmentById(R.id.my_nav_host_fragment)
    if (target is OnBackPressHandler) {
        if (target.onBackPressed()) {
            return
        }
    }
    super.onBackPressed()
}

順を追って説明していきます。

NavHostFragmentの取得

まずは1行目のnavHostの部分を解説します。

val navHost = supportFragmentManager.findFragmentById(R.id.my_nav_host_fragment) as NavHostFragment

NavHostFragmentとはNavigationで画面遷移を行う場合のRootに相当するFragmentです。このNavHostFragmentの上で、childFragmentとして画面遷移を行うのがNavigationです。
サンプルコードではNavHostFragmentMainActivityのレイアウトに以下のように定義されています。

navigation_activity.xml
<LinearLayout ...>
    <android.support.v7.widget.Toolbar .../>
    <fragment
        android:id="@+id/my_nav_host_fragment"
        android:name="androidx.navigation.fragment.NavHostFragment"
        app:navGraph="@navigation/mobile_navigation"
        app:defaultNavHost="true"
        .../>
    <android.support.design.widget.BottomNavigationView .../>
</LinearLayout>

よって、Navigationで管理されたFragmentをMainActivityで取得するためには、上記のような処理になります。

NavHostFragmentから現在表示中の画面(Fragment)を取得する

続いて2行目の部分です。

val target = navHost.childFragmentManager.findFragmentById(R.id.my_nav_host_fragment)

NavHostFragmentには基本的に2つのchildFragmentがattachされています。ひとつは現在表示中のFragment、もう一つはStateFragmentです。
結論から言うと、現在表示中のFragmentはNavHostFragmentのIDがそのまま現在表示中のFragmentのIDとして扱われています。そのため、上記のように素直なコードで現在表示中のFragmentが取得できます。

残りのStateFragmentですが、Navigationの内部で自動生成されるUIを持たないFragmentです。コードはとても短く以下のようになっています。

FragmentNavigator.java
@RestrictTo(RestrictTo.Scope.LIBRARY)
public static class StateFragment extends Fragment {
    static final String FRAGMENT_TAG = "android-support-nav:FragmentNavigator.StateFragment";

    private static final String KEY_CURRENT_DEST_ID = "currentDestId";

    int mCurrentDestId;

    @Override
    public void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        if (savedInstanceState != null) {
            mCurrentDestId = savedInstanceState.getInt(KEY_CURRENT_DEST_ID);
        }
    }

    @Override
    public void onSaveInstanceState(Bundle outState) {
        super.onSaveInstanceState(outState);
        outState.putInt(KEY_CURRENT_DEST_ID, mCurrentDestId);
    }
}

使われ方もとてもシンプルです。

FragmentNavigator.java
@Override
public void navigate(@NonNull Destination destination, @Nullable Bundle args,
                        @Nullable NavOptions navOptions) {
    final Fragment frag = destination.createFragment(args);
    final FragmentTransaction ft = mFragmentManager.beginTransaction();
    (途中略)
    ft.replace(mContainerId, frag);
    final StateFragment oldState = getState();
    if (oldState != null) {
        ft.remove(oldState);
    }
    final @IdRes int destId = destination.getId();
    final StateFragment newState = new StateFragment();
    newState.mCurrentDestId = destId;
    ft.add(newState, StateFragment.FRAGMENT_TAG);
    (以下略)

要はNavigationで使っているIDをmCurrentDestIdとして保持するためだけにStateFragmentは存在しています。
ちなみに、現在表示中のFragmentのIDは上記でわかるようにmContainerIdで指定されています。mContainerIdFragmentNavigatorのコンストラクタで渡されており、さらにこのコンストラクタはNavHostFragmentgetId()を使われていることがわかります。

FragmentNavigator.java
public FragmentNavigator(@NonNull Context context, @NonNull FragmentManager manager, int containerId) {
    mContext = context;
    mFragmentManager = manager;
    mContainerId = containerId;

    mBackStackCount = mFragmentManager.getBackStackEntryCount();
    mFragmentManager.addOnBackStackChangedListener(mOnBackStackChangedListener);
}
NavHostFragment.java
/**
 * Create the FragmentNavigator that this NavHostFragment will use. By default, this uses
 * {@link FragmentNavigator}, which replaces the entire contents of the NavHostFragment.
 * <p>
 * This is only called once in {@link #onCreate(Bundle)} and should not be called directly by
 * subclasses.
 * @return a new instance of a FragmentNavigator
 */
@NonNull
protected Navigator<? extends FragmentNavigator.Destination> createFragmentNavigator() {
    return new FragmentNavigator(getContext(), getChildFragmentManager(), getId());
}

Fragmentでのイベント受け取り

最初に作ったOnBackPressHandleをimplementしてメソッドを実装します。

XXXFragment.kt
override fun onBackPressed(): Boolean {
    AlertDialog.Builder(requireContext())
            .setMessage("現在修正中の内容を破棄して前の画面に戻ってもよろしいですか?")
            .setPositiveButton("設定を破棄する") { _, _ ->
                Navigation.findNavController(requireActivity(), R.id.my_nav_host_fragment).popBackStack()
            }
            .setNegativeButton("編集を続ける") { _, _ -> }
            .show()
    return true
}

trueを返却することでバックキーイベントは消化されたことになるので、一律trueを返却します。今回の例の場合はダイアログを出し、positiveButtonを選択された場合は普段通りバックキーと同等の処理をしたいので、以下のような実装をしています。

Navigation.findNavController(requireActivity(), R.id.my_nav_host_fragment).popBackStack()

これでNavigationを使ってバックキーと同等の画面遷移ができます。

追記:2018/08/02 shibuya.apk #27 にてこの内容を発表しました。そのときの資料はこちらです。
https://speakerdeck.com/kosukematsumura/navigationfalsehatukukihantorinku