BottomNavigation のタブごとに Fragment の遷移履歴残すやつ

Fragment を使った画面遷移メモ を書いたときに後から追記しようと思って完全に忘れていた。

言葉ではわかりにくいのですが、以下のような画面遷移を実現するやつです。

基本的にAndroidアプリではタブごとに深い階層を持たずにActivityで遷移していったほうが良いと思っていますが、どうしても必要な場合には FragmentManager の primary navigation fragment を利用するとシンプルに実装できます。

サンプルコードは以下に置いてあります。 github.com

はじめに

前提として、 FragmentManager の以下の仕組みを理解しておく必要があります

detach()

https://developer.android.com/reference/android/support/v4/app/FragmentTransaction#detach

detach() は Fragment を UI から取り外すことができるメソッドです。このとき、対象FragmentにネストされたFragmentも同時にUIから取り外されます。 UIから取り外された Fragment ではライフサイクルが onDestroyView() まで進み、Viewが破棄されます。 重要なのは、 detach() された Fragment やネストされた Fragment の savedInstanceState はそのまま残るということです。 childFragmentManager のバックスタックなどの情報も savedInstanceState に保存されるため、 detach() されたFragmentはネストされたFragmentの遷移履歴を残したままUIから非表示にできます

github.com

detach() した Fragment は後述する attach() で再度UIに追加することができます。

attach()

https://developer.android.com/reference/android/support/v4/app/FragmentTransaction#attach

detach() された Fragment を再度 UI に追加することができます。このとき、ネストされたFragmentも含めすべてのViewが再生成されます。 また、detach() でも説明したとおり、ネストされたFragmentの遷移履歴も復元されます。

つまり、タブごとに Fragment を用意してその childFragmentManager でアプリ内の画面遷移を行い、タブ切り替えをタブFragmentの detach() / attach() で行えば遷移履歴を残したままタブ表示を切り替えることができるようになります。 ただし、 Activity が持つ FragmentManager はそのままでは popBackStack() したときに childFragmentManager のバックスタックを見てくれないので、後述の setPrimaryNavigationFragment() を組み合わせる必要があります。

setPrimaryNavigationFragment()

https://developer.android.com/reference/android/support/v4/app/FragmentTransaction#setPrimaryNavigationFragment(android.support.v4.app.Fragment)

setPrimaryNavigationFragment() は画面遷移を処理するFragmentを指定するメソッドです。 対象Fragmentを setPrimaryNavigationFragment() しておくことで popBackStack() したときにそのFragmentの childFragmentManager のバックスタックを戻れる ようになります。

さきほど説明したタブ切り替えの際、 attach() と同時に setPrimaryNavigationFragment() しておくことで バックボタン押下時にタブFragmentの childFragmentManager の popBackStack() を呼び出してくれます。

バックボタンを押した際の挙動まとめ:

  1. PrimaryNavigationFragment.childFragmentManager.popBackStack()
    • PrimaryNavigationFragment が設定されていなければ 2. へ
    • バックスタックがなければ 2. へ
  2. FragmentActivity.supportFragmentManager.popBackStack()
    • バックスタックがなければ 3. へ
  3. アプリ終了

サンプル解説

ここまでで説明したとおり、実装方法はシンプルです。

  • タブごとに Fragment (TabFragment)を生成し、 childFragmentManager を管理させる
  • タブ切り替えの実装を各TabFragment の detach/attach によって行う
  • コンテンツ間の画面遷移に TabFragment の childFragmentManager を利用する
    • Fragment.getFragmentManager は自身の遷移に利用された FragmentManager を返す
      • TabFragment で最初のFragmentを追加する際に利用した childFragmentManager がそのまま利用できる
    • Fragment.getId は自身の遷移に利用された layout id を返すを返す
      • TabFragment で最初のFragmentを追加する際の id がそのまま利用できる

タグの切り替え、および初回の TagFragment の初期化

https://github.com/nein37/BottomNavigationSample/blob/master/app/src/main/java/com/github/nein37/bottomnavigationsample/MainActivity.kt

private fun changeBottomNav(tag: String) {

  // 各タブごとの TabFragment を検索
  val homeFragment = supportFragmentManager.findFragmentByTag("home");
  val dashboardFragment = supportFragmentManager.findFragmentByTag("dashboard");
  val notificationFragment = supportFragmentManager.findFragmentByTag("notification");

  // 遷移先の TabFragment を設定
  val targetFragment = when (tag) {
      "home" -> homeFragment
      "dashboard" -> dashboardFragment
      "notification" -> notificationFragment
      else -> throw IllegalArgumentException()
  }

  supportFragmentManager.beginTransaction().apply {
      supportFragmentManager.primaryNavigationFragment?.let {
          // すでに primaryNavigationFragment が set されている場合は detach
          // detach された Fragment の View は破棄されるが childFragmentManager の backStack などは保持される
          detach(it)
      }

      if (targetFragment == null) {
          // 初回のみタブごとの TabFragment を作成して add する
          val primaryNavigationFragment = TabFragment().apply {
              arguments = Bundle().apply { putString("title", tag) }
          }
          add(R.id.contents, primaryNavigationFragment, tag)

          // TabFragment に設定することで popBackStack() する際に TabFragment の childFragmentManager の backStack があればそれを pop するようになる
          setPrimaryNavigationFragment(primaryNavigationFragment)
      } else {
          // すでに TabFragment が存在する場合は attach
          attach(targetFragment)
          // TabFragment 再設定
          setPrimaryNavigationFragment(targetFragment)
      }
  }.commit()
}

TabFragment の初回表示時に childFragmentManager と layout id を利用して初期表示Fragmentを設定

https://github.com/nein37/BottomNavigationSample/blob/master/app/src/main/java/com/github/nein37/bottomnavigationsample/TabFragment.kt

// 初回のみ初期表示Frgmentを設定
if (childFragmentManager.findFragmentById(R.id.tab_contents) == null) {
    childFragmentManager.beginTransaction().apply {
        val firstFragment = ContentsFragment()
        replace(R.id.tab_contents, firstFragment)
    }.commit()
}

コンテンツ間の遷移で TabFragment の childFragmentManager および layout id を利用する

// Fragment.requireFragmentManager() は自分自身の遷移に利用された FragmentManagerを返す
// (ここでは PrimarynavigationFragment の childFragmentmanager )
requireFragmentManager().beginTransaction().apply {
    val nextFragment = ContentsFragment().apply {
        arguments = Bundle().apply {
            // 現在のページ数に +1 して次のページへ
            putInt("key", currentPage + 1)
        }
        addToBackStack(null)
    }
    // Fragment.getId() は自分自身の遷移に利用された id
    // (ここでは R.id.tab_contents)
    replace(id, nextFragment)
}.commit()

これだけで BottomNavigation のタブごとに Fragment の遷移履歴残すやつ の完成です。

注意点

ViewPager などでコンテンツFragmentのchildFragmentManager を利用してさらにFragmentをネストする場合、各Fragmentの getFragmentManager が TabFragment.childFragmentManager と一致しなくなります。 その場合は getActivity().supportFragmentManager().getPrimaryNavigationFragment.childFragmentManager() のような呼び出しで TabFragment.childFragmentManager にアクセスするしかありません。 Kotlin の場合は以下のような拡張関数を生やすと便利かもしれません。

fun Fragment.requirePrimaryNavigationFragmentManager() =
        requireActivity().supportFragmentManager.primaryNavigationFragment?.let {
            it.childFragmentManager
        } ?: requireFragmentManager()