🛤️

Android: BottomNavigationの要素を再選択して子画面から親画面に戻す一貫した実装

2022/07/08に公開

概要

この記事では、Bottom navigationで現在選択されている行先のアイコンを再度タップすることにより、その行先のトップの画面(ここでは親画面と呼びます)へと戻る処理を一貫した形で実装します。
これにより、ユーザーは複数の階層を下った画面からでも1タップで親画面へ戻ることができるようになります。
また、親画面で再タップした時に(親画面にあるRecyclerViewをトップまでスクロールするなどの)特定のアクションを起こせるような実装も同時に行います。

a.gif

環境

  • Kotlin 1.5.31
  • Androidx.Navigation 2.3.5

テクニック

アイテムを再選択したことの検知

BottomNavigationView#setOnNavigationItemReselectedListener(listener) を使います。

// ui/MainActivity.kt

binding.bottomNavigation.setOnNavigationItemReselectedListener { menuItem ->
  // ...
}

これによって、BottomNavigationのアイテムを再選択したときにそのアイテムを引数に取るリスナーが発火します。

子画面から親画面へと戻らせる

子画面に一々親画面へ戻る処理を書くのは追加漏れや疎結合性などの観点から避けたいです。なので、子画面から親画面に戻る処理をボトムナビゲーションを配置しているActivity側[1]で記述します。

// ui/MainAcitivity.kt

binding.bottomNavigation.setOnNavigationItemReselectedListener { menuItem ->
    // メニューアイテムのidと対応する親画面のidのmap
    val menuItemIdToFragmentIdMap = mapOf(
        R.id.menu_home to R.id.homeFragment,
        // ...
    )
    val rootDestinationIds = menuItemIdToFragmentIdMap.values
    val currentId = navController.currentDestination?.id
        ?: return@setOnItemReselectedListener
    val rootId = menuItemIdToFragmentIdMap[menuItem.itemId]
        ?: return@setOnItemReselectedListener

    if (currentId in rootDestinationIds) {
        // 親画面だった時の処理
    } else {
        // 子画面だった時の処理
        navController.popBackStack(rootId, false)
    }
}

予め、menuItemIdToFragmentIdMapにボトムナビゲーションのメニューアイテムとそれに対応した親Fragmentのidのマップを作っておき、現在いるFragmentのidが親Fragmentのセットに含まれるかどうかで子画面かどうかを判別します。もし子画面であれば、そのidを元に親FragmentへNavController#popBackStack(destinationId, inclusive)でポップします。これで、Fragmentの再生成を行わず状態を保持したまま親画面へと戻す処理が出来ました。

また、親のFragmentのセットをナビゲーショングラフから取得する別の方法として以下のようなものもあります。 [2]

val rootDestinationIds = navController.graph
    .map { if (it is NavGraph) it.startDestination else it.id }
    // トップのナビゲーショングラフがグラフしか持たないことを想定するなら
    //  .mapNotNull { (it as? NavGraph)?.startDestination } でも良い
    .toSet()

ただし、この方法はボトムナビゲーションのグラフがアイテム毎にその画面のグラフを持たせていることを前提としてそのグラフにある行先を集めているだけなので、ボトムナビゲーションの画面ごとにナビゲーショングラフを別に作っておく必要があります。

親画面での再選択イベントを親画面のViewModelに流す

さて、これで子画面から親画面へと戻る処理については実装できましたが、親画面でアイテムを再選択したときの処理を扱うことが出来ていません。親画面での処理をActivity側で行うのは非現実的なので、SharedFlowを使って親画面のViewModelにアイテムの再選択についてのイベントを流し、それを親画面側で受け取ることで対応します。

まず選択した画面を表現する列挙型クラスを作成します。

// model/MainBottomNavigationSelectedItem.kt

enum class MainBottomNavigationSelectedItem {
    HOME,
    DASHBOARD,
    NOTIFICATIONS;

    fun isHome(): Boolean = (this == HOME)
    fun isDashboard(): Boolean = (this == DASHBOARD)
    fun isNotifications(): Boolean = (this == NOTIFICATIONS)
}

そして、MainActivity側のViewModelで親画面での再選択イベントを流すためのSharedFlowを作成し、再選択時に発火するようにします。

// ui/MainViewModel.kt

class MainViewModel : ViewModel() {

    private val reselectedItemOnRootSource: MutableSharedFlow<MainBottomNavigationSelectedItem> =
        MutableSharedFlow()
    val reselectedItemOnRoot: SharedFlow<MainBottomNavigationSelectedItem> =
        reselectedItemOnRootSource.asSharedFlow()

    fun reselectBottomNavigationItemOnRoot(@IdRes selectedMenuId: Int) {
        val reselected = when (selectedMenuId) {
            // ナビゲーション上での親画面のid
            R.id.homeFragment -> MainBottomNavigationSelectedItem.HOME
            R.id.dashboardFragment -> MainBottomNavigationSelectedItem.DASHBOARD
            R.id.notificationsFragment -> MainBottomNavigationSelectedItem.NOTIFICATIONS
            else -> return
        }

        viewModelScope.launch {
            reselectedItemOnRootSource.emit(reselected)
        }
    }

}
// ui/MainActivity.kt

if (currentId in rootDestinationIds) {
    // 親画面だった時の処理
    viewModel.reselectBottomNavigationItemOnRoot(rootId)
} else {
    // 子画面だった時の処理
    // ...
}

最後に、activityViewModels()でそれぞれの親画面からreselectedItemOnRootを購読することで親画面での再選択時のイベントを扱うことができます。

// ui/home/HomeFragment.kt

class HomeFragment : Fragment() {
    
    // ...

    private val mainViewModel by activityViewModels<MainViewModel>()

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        // ...

        lifecycleScope.launch {
            mainViewModel.reselectedItemOnRoot
                .filter { it.isHome() } // 再選択した画面が自分の画面でない可能性を弾く
                .collect {
                    // 親画面でアイテムが再選択されたときの処理
                    binding.recycler.smoothScrollToPosition(0)
                }
        }

        // ...
    }

}

デモ

以下のリポジトリにこの記事のデモがあります。

https://github.com/blackbracken/bottom-navigation-back-demo

脚注
  1. 簡単のためにActivityとしているが、実際はMainActivityの下にMainFragmentなどを挟んでいる場合もあると思うので、そういった場合は適宜読み替えてほしい。 ↩︎

  2. popBackstackする時にidが必要になるので結局のところコード上にidは書かざるを得ないが、その時にはwhen式で書くことになるので、評価する式によっては網羅的なチェックを入れられてコンパイル時に追加漏れを弾けるメリットがある。 ↩︎

Discussion