はじめに
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を用意します
interface OnBackPressHandler {
fun onBackPressed(): Boolean
}
Activityでのイベント受け取り
次にバックキーイベントの最初の受け取り口であるActivity(MainActivity
)でイベントを受け取ります。
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です。
サンプルコードではNavHostFragment
はMainActivity
のレイアウトに以下のように定義されています。
<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です。コードはとても短く以下のようになっています。
@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);
}
}
使われ方もとてもシンプルです。
@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
で指定されています。mContainerId
はFragmentNavigator
のコンストラクタで渡されており、さらにこのコンストラクタはNavHostFragment
のgetId()
を使われていることがわかります。
public FragmentNavigator(@NonNull Context context, @NonNull FragmentManager manager, int containerId) {
mContext = context;
mFragmentManager = manager;
mContainerId = containerId;
mBackStackCount = mFragmentManager.getBackStackEntryCount();
mFragmentManager.addOnBackStackChangedListener(mOnBackStackChangedListener);
}
/**
* 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してメソッドを実装します。
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