BottomNavigationViewとJetpack Navigationを組み合わせた画面遷移の実装の勘所

こんにちは。ギフトモールで Android アプリの開発をしている @KeithYokoma です。

スマホアプリで一般的な UI のパターンのひとつに Bottom Navigation (いわゆる下タブ)があります。Android においては、Jetpack ライブラリにある BottomNavigationView を使うことでかんたんに Bottom Navigation の UI を構築できます。Bottom Navigation の UI は画面遷移の実装もセットになっており、Jetpack Navigation と Fragment、BottomNavigationView を組み合わせて画面遷移を実装します。

この記事では、BottomNavigationView と Jetpack Navigation を組み合わせた Fragment ベースの画面遷移の実装にけおる勘所を紹介しようと思います。

BottomNavigationView と Jetpack Navigation を組み合わせた画面遷移の基本

BottomNavigationView はタブによる画面遷移のための UI を提供します。タブは5つまで設定可能で、名前の通りアプリの画面下部に配置して使います。

BottomNavigationView のタブは menu リソースで定義します。次のコード例は3つのタブを定義し、それぞれにアイコンとラベルを設定しています。

<menu xmlns:android="http://schemas.android.com/apk/res/android">

  <item
    android:id="@+id/tab_home"
    android:icon="@drawable/ic_home_black_24dp"
    android:title="@string/title_home" />

  <item
    android:id="@+id/tab_dashboard"
    android:icon="@drawable/ic_dashboard_black_24dp"
    android:title="@string/title_dashboard" />

  <item
    android:id="@+id/tab_notifications"
    android:icon="@drawable/ic_notifications_black_24dp"
    android:title="@string/title_notifications" />

</menu>

menu リソースで定義したタブを BottomNavigationView に設定するコード例は次のとおりです。このコード例では XML で読み込んでいますが、Kotlin や Java のコードから menu リソースを読み込むことも可能です。

<com.google.android.material.bottomnavigation.BottomNavigationView
  android:id="@+id/nav_view"
  android:layout_width="match_parent"
  android:layout_height="wrap_content"
  android:background="?android:attr/windowBackground"
  app:menu="@menu/bottom_nav_menu" />

ここまでで BottomNavigationView をつかったタブの構築ができました。次にタブを切り替えたときの画面遷移を実装します。今回は Jetpack Navigation と Fragment を組み合わせた画面遷移を構築してみます。 先ほど menu リソースで定義したタブに対応する画面としてそれぞれ、ホーム画面(HomeFragment )、ダッシュボード画面(DashboardFragment )、通知画面(NotificationsFragment )を作成した状態で、次のような Navigation Graph を XML で定義します。BottomNavigationView と Jetpack Navigation を組み合わせて Navigaiton Graph を構築する場合、menu リソースで定義している各タブの ID と、Navigation Graph におけるタブに対応する画面の ID を一致させることに注意しましょう。

<navigation
  xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  android:id="@+id/mobile_navigation"
  app:startDestination="@+id/tab_home">

  <!-- fragment の ID を menu resource の <item> で設定した ID と一致させる -->
  <fragment
    android:id="@+id/tab_home"
    android:name="jp.co.giftmall.sample.navigation.ui.home.HomeFragment"
    android:label="@string/title_home" />

  <fragment
    android:id="@+id/tab_dashboard"
    android:name="jp.co.giftmall.sample.navigation.ui.dashboard.DashboardFragment"
    android:label="@string/title_dashboard" />

  <fragment
    android:id="@+id/tab_notifications"
    android:name="jp.co.giftmall.sample.navigation.ui.notifications.NotificationsFragment"
    android:label="@string/title_notifications" />

</navigation>

Navigation Graph が定義できたら、画面遷移を管理する要素を BottomNavigationView と同じレイアウトに配置します。

<androidx.fragment.app.FragmentContainerView
  android:id="@+id/nav_host_fragment_activity_main"
  android:name="androidx.navigation.fragment.NavHostFragment"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  app:defaultNavHost="true"
  app:navGraph="@navigation/mobile_navigation" />

最後に、Activity で BottomNavigationView と Jetpack Navigation の NavController を連動させればタブ切り替えによる画面遷移が完成します。Jetpack Navigation はライブラリとして便利な拡張関数(BottomNavigationView#setupWithNavController )を提供しており、Android Studio で作成できる Bottom Navigation のひな形でも拡張関数を使って実装しています。

class MainActivity : AppCompatActivity() {
  private lateinit var binding: ActivityMainBinding

  override fun onCreate(savedInstanceState: Bundle?) {
    // ...

    val navView: BottomNavigationView = binding.navView
    val fragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment_activity_main) as NavHostFragment
    val navController = fragment.navController
    val appBarConfiguration = AppBarConfiguration(TAB_IDS)
    navView.setupWithNavController(navController)
  }

  companion object {
    private val TAB_IDS = setOf(R.id.tab_home, R.id.tab_dashboard, R.id.tab_notifications)
  }
}

BottomNavigationView 以外で遷移する画面の構成

アプリを作り込んでいくと、タブに対応する画面以外の画面も作りたくなります。例えば、新しく設定画面(SettingsFragment)を追加するケースを考えてみると、Navigation Graph の XML は次のように変化します。

<navigation
  xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  android:id="@+id/mobile_navigation"
  app:startDestination="@+id/tab_home">

  <fragment
    android:id="@+id/tab_home"
    android:name="jp.co.giftmall.sample.navigation.ui.home.HomeFragment"
    android:label="@string/title_home" />

  <fragment
    android:id="@+id/tab_dashboard"
    android:name="jp.co.giftmall.sample.navigation.ui.dashboard.DashboardFragment"
    android:label="@string/title_dashboard" />

  <fragment
    android:id="@+id/tab_notifications"
    android:name="jp.co.giftmall.sample.navigation.ui.notifications.NotificationsFragment"
    android:label="@string/title_notifications" />

  <!-- 新たに設定画面を追加する -->
  <fragment
    android:id="@+id/navigation_settings"
    android:name="jp.co.giftmall.sample.navigation.ui.settings.SettingsFragment"
    android:label="@string/title_settings" />

</navigation>

より細かく画面遷移の動き(アニメーションなど)を制御したい場合は別途 <action> を定義することになりますが、単純に設定画面を開くだけであれば Navigation Graph の変更は画面を追加するだけで済みます。 Activity や Fragment では、設定画面の ID を使って NavController#navigate(Int) を呼び出すだけで画面遷移が実現できます。

BottomNavigationView のタブごとにバックスタックを保持する(Multiple Back Stacks)

Jetpack Navigation 2.3.x 以前は NavController が管理するバックスタックは常にひとつでした。このため、例えば次のような順序で画面遷移をすると、設定画面ではなくホーム画面が表示されていました。この挙動そのものは Material Design のガイドラインに沿って作られています。

アプリ起動 -> ホーム画面を開く -> 設定画面を開く -> ダッシュボードタブを選択する -> ダッシュボード画面が開く -> ホームタブを選択する -> ホーム画面が開く

Jetpack Navigation 2.4.x へのアップデート時にバックスタックの管理方法が見直され、タブごとに個別のバックスタックを保持できるようになりました。これは Multiple Back Stacks と呼ばれ、BottomNavigationView や NavigationDrawer と組み合わせたユースケースにおいて、タブやメニューごとに画面遷移の履歴を保持します。この Multiple Back Stacks により、先ほどの画面遷移が次のように変わります。

アプリ起動 -> ホーム画面を開く -> 設定画面を開く -> ダッシュボードタブを選択する -> ダッシュボード画面が開く -> ホームタブを選択する -> 設定画面が開く

Material Design のガイドラインとしてはどちらの挙動も許容しており、Android においては Multiple Back Stacks 導入以前の挙動を基本としながらも、場合によっては使いやすさを優先して Multiple Back Stacks 導入後の挙動に変更することも許容しています。

正しく Multiple Back Stacks を扱う

実は Multiple Back Stacks を適切に扱うには Jetpack Navigation のバージョンを 2.4.x 以降にアップデートするだけでは不十分で、いくつかのケースで画面遷移が期待通りの動きにならなかったり、そもそも画面遷移自体ができなくなったりしてしまいます。

たとえば、Deep Link を使って sample-app://nav/dashbaord というリンクからダッシュボード画面を開きたい場合、次のように <fragment> 要素の子として <deepLink> を追加します 1

<navigation
  xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  android:id="@+id/mobile_navigation"
  app:startDestination="@+id/tab_home" >

  <fragment
    android:id="@+id/tab_dashboard"
    android:name="jp.co.giftmall.sample.navigation.ui.dashboard.DashboardFragment"
    android:label="@string/title_dashboard" >

    <deepLink
      app:uri="sample-app://nav/dashboard" />

  </fragment>

</navigation>

上記のコードをもとにディープリンクでアプリを起動すると、ダッシュボードタブが選択状態になりダッシュボード画面が開きます。一見するとディープリンクが動作しているように見えますが、この状態でホームタブを選択すると画面が切り替わらないことに気が付きます。戻すジェスチャーや戻るボタンには反応してタブ切り替えができていますが、タブ切り替えのみ不具合が発生しています。根本的な原因は Navigation Graph にあり、Navigation Graph においてタブに対応する要素が <fragment> の場合は Multiple Back Stacks が正しく扱われなくなるため、ホーム画面へ戻るときに不具合を生じてしまいます。

正しく Multiple Back Stacks に対応するには、次のように Navigation Graph を入れ子にする Nested Navigation Graph を構築する必要があります 2, 3。このとき、タブに対応する画面のみを Nested Navigation Graph とすることに注意してください。BottomNavigationView のタブと紐付かない画面は、どのタブからも共通して遷移可能な画面はトップレベルの Navigation Graph に組み込み、特定のタブからのみ遷移可能な画面はそのタブに対応する Nested Navigation Graph に組み込みます。

<navigation
  xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  android:id="@+id/mobile_navigation"
  app:startDestination="@+id/tab_home">

  <!-- Nested Navigation Graph の ID を menu resource の ID と一致させる -->
  <navigation
    android:id="@+id/tab_home"
    android:label="@string/title_home"
    app:startDestination="@+id/navigation_home">

    <fragment
      android:id="@+id/navigation_home"
      android:name="jp.co.giftmall.sample.navigation.ui.home.HomeFragment"
      android:label="@string/title_home" />

  </navigation>

  <navigation
    android:id="@+id/tab_dashboard"
    android:label="@string/title_dashboard"
    app:startDestination="@+id/navigation_dashboard">

    <fragment
      android:id="@+id/navigation_dashboard"
      android:name="jp.co.giftmall.sample.navigation.ui.dashboard.DashboardFragment"
      android:label="@string/title_dashboard" >

      <deepLink
        app:uri="sample-app://nav/dashboard" />
    
      <deepLink
        app:uri="https://example.com/nav/dashboard"
        app:autoVerify="true" />

    </fragment>

  </navigation>

  <navigation
    android:id="@+id/tab_notifications"
    android:label="@string/title_notifications"
    app:startDestination="@+id/navigation_notifications">

    <fragment
      android:id="@+id/navigation_notifications"
      android:name="jp.co.giftmall.sample.navigation.ui.notifications.NotificationsFragment"
      android:label="@string/title_notifications" />

  </navigation>

  <!-- BottomNavigationView のタブと紐付かず、どの Nested Navigation Graph からも遷移可能な画面はトップレベルの Navigation Graph の子として定義する -->
  <fragment
    android:id="@+id/navigation_settings"
    android:name="jp.co.giftmall.sample.navigation.ui.settings.SettingsFragment"
    android:label="@string/title_settings" />

</navigation>

Nested Navigation Graph をつかうことで、Deep Link によるアプリ起動後のタブ切り替えが正しく動作するようになりました。

画面の切り替えに合わせてタブの選択状態を正しく反映する (タブ内で画面遷移をしたとき)

Jetpack Navigation 2.4.x 以降では Multiple Back Stacks によりタブごとにバックスタックが管理されますが、この Multiple Back Stacks の影響でタブの選択状態がうまく反映されなくなるケースが存在します。

たとえば次のような手順で操作を行った場合、最後にホームタブを選択した時点でバックスタック自体はホームタブのものに切り替わっていますが、タブの選択状態はダッシュボードのままになります。

アプリ起動 -> ホーム画面を開く -> 設定画面を開く -> ダッシュボードタブを選択する -> ダッシュボード画面が開く -> 設定画面を開く -> ホームタブを選択する

この手順に沿って操作したときのスクリーンショットは次のとおりです。ホームタブを選択した後も BottomNaivgationView のタブの選択状態が更新されないままになっています。

BottomNavigationView のタブ切り替えと Jetpack Navigation での画面切り替えの連携は BottomNavigationView#setupWithNavController で実現できます。この関数では、BottomNavigationView#setOnItemSelectedListener を使い未選択状態のタブを選択したことを検知し NavigationUI.onNavDestinationSelected(MenuItem, NavController) で画面切り替えを実現しています。setOnItemSelectedListener は返り値として Boolean を返すように定義しており、true を返すとタブの選択状態を更新するようになっています。BottomNavigationView#setupWithNavController の実装をみてみると、setOnItemSelectedListenerNavigationUI.onNavDestinationSelected(MenuItem, NavController) の返り値をそのまま返しています。

NavigationUI.onNavDestinationSelected(MenuItem, NavController) は MenuItem の ID (選択したタブの ID)と切り替えた画面の ID が一致するときのみ true を返すため、選択したタブのバックスタックの先頭にある画面がタブの ID と一致しないケースではタブの選択状態が更新されないことになります。

もともと Material Design の Android 向けガイドラインでは、未選択状態のタブを選んだときにはそのタブが持っていたバックスタックはクリアすることを基本の動作(M2の場合)としており、BottomNavigationView#setupWithNavController もこのガイドラインに基づいて作られています。このため、バックスタックを維持したときの挙動は自分で実装しなければなりません。

次のコードは常に選択状態を更新し続けるようにする実装です。BottomNavigationView#setupWithNavController の実装を上書きするため、BottomNavigationView#setupWithNavController の後に実行されるようにするか、BottomNavigationView#setupWithNavController そのものを使わないようにする必要があります。

navView.setOnItemSelectedListener { item ->
  NavigationUI.onNavDestinationSelected(item, navController)
  // onNavDestinationSelected は MenuItem に対応する child navigation graph のバックスタックから適切な Destination を呼び戻す。
  // Multiple Backstack をサポートする Jetpack Navigation 2.4.0 以後、child navigation graph のバックスタックから呼び戻される Destination は
  // child navigation graph が管理しているバックスタックの先頭にある Destination である。
  // ここでその Destination が Bottom Navigation のタブに対応する Destination でないとき、NavigationUI.onNavDestinationSelected の返り値は false となり
  // それをそのまま OnItemSelectedListener の返り値としてしまうとタブを選択して画面を切り替えたのにタブの選択状態が変わらない現象が発生してしまう。
  // この現象を回避するために setupWithNavController の内部で実行している BottomNavigationView#setOnItemSelectedListener を上書きし、
  // OnItemSelectedListener が常に true を返すことで呼び戻した Destination に関係なくタブの選択状態が変わるようにする。
  // BottomNavigationViwe#setupWithNavController でも BottomNavigationView#setOnItemSelectedListener を呼び出しているので、
  // この上書きは必ず BottomNavigationView#setupWithNavController のあとに実行する。
  true
}

この場合、次に示すスクリーンショットのように常にタブ切り替えが発生します。

タブの選択状態は Deep Link でアプリを起動した時にも考慮が必要です。

Jetpack Navigation の Deep Link では URL によるものの他、NavDeepLinkBuilderPendingIntent を作って Deep Link とするものもあります。通知からアプリの画面を開く場合には NavDeepLinkBuilder を使った実装が便利です。NavDeepLinkBuilder ではアプリ起動時にあらかじめバックスタックに積むべき画面を設定できます。次のコードでは、ダッシュボード画面を開いた上でさらに設定画面を開く Deep Link を作っています。アプリを起動してユーザーが目にする画面は設定画面ですが、バックスタックにはダッシュボード画面(とホーム画面)がある状態になります。

// tab_dashboard と navigation_settings をバックスタックに積む DeepLink を作成する。
// この場合アプリ起動後のバックスタックには
// 1. トップレベルの NavGraph の Start Destination (tab_home)
// 2. tab_dashboard
// 3. navigation_settings
// の 3 つが存在することになる。
val settingsIntent = NavDeepLinkBuilder(requireContext())
  .setGraph(R.navigation.mobile_navigation)
  .addDestination(R.id.tab_dashboard)
  .addDestination(R.id.navigation_settings)
  .setComponentName(ComponentName(requireContext().packageName, MainActivity::class.java.name))
  .createPendingIntent()

この PendingIntent でアプリを起動すると、BottomNavigationView のタブはホームのタブが選択状態になります。しかしバックスタックの内容としては、ダッシュボード画面を一度開いたことにしているため、ダッシュボードのタブを選択状態にしたくなります。

バックスタックの内容に応じてタブの選択状態を変える例を次のコードで示しています。 NavController#addOnDestinationChangedListener で画面の切り替わりを検知し、都度バックスタックのなかで最も最近の BottomNavigationView のタブに対応する ID を持った Destination を確認します。最終的に BottomNavigationView から MenuItem を取り出してきて、対応する ID の MenuItem を checked 状態に変更します。 BottomNavigationView#setSelectedItemId(int) を使わず少し回りくどい手続きを踏んでいるのは、OnItemSelectedListener が呼ばれるのを避け、選択状態だけを変更するためです(Jetpack Navigation ライブラリ内部でも同様のことをしている箇所があります)。

val navView: BottomNavigationView = binding.navView
val weakRefToNavView: WeakReference<BottomNavigationView> = WeakReference(navView)

// NavDeepLinkBuilder で作成した PendingIntent でアプリを起動した場合、Activity#onNewIntent は呼ばれないため
// OnDestinationChangedListener を使う。
navController.addOnDestinationChangedListener(
  object : NavController.OnDestinationChangedListener {
    override fun onDestinationChanged(
      controller: NavController,
      destination: NavDestination,
      arguments: Bundle?
    ) {
      // 大まかな処理の流れ自体は BottomNavigationView#setupWithNavController と同じ
      val navigationView = weakRefToNavView.get()
      if (navigationView == null) {
        navController.removeOnDestinationChangedListener(this)
      } else {
        val latestTabDestination = controller.backQueue.lastOrNull { entry ->
          TAB_IDS.contains(entry.destination.id)
        }?.destination ?: return
        // navView.selectedItemId = latestTabDestination.id では OnItemSelectedListener が発火してしまい
        // NavigationUI.onNavDestinationSelected(item, navController) まで呼ばれて意図しない動作となるため
        // 選択状態だけを切り替えたい (setupWithNavController でも同様に、selectedItemId を変えるのではなく MenuItem の isChecked を切り替えている)。
        navigationView.menu.findItem(latestTabDestination.id).isChecked = true
        navigationView.selectedItemId
      }
    }
  }
)

この処理を組み込むと、次のスクリーンショットのように起動後からダッシュボードが選択状態になります。

BottomNavigationView のタブの再選択の振る舞いを実装する

BottomNavigationView で選択状態になっているタブは再度タップする(再選択)操作ができ、再度タップしたとき専用のリスナーも定義してありますが、選択状態のタブをタップした時にどのような振る舞いをするべきかは自分自身で定義・実装する必要があります。 Material Design のガイドラインでは、スクロール可能なコンテンツを表示している画面であれば一番上までスクロールする動作をする例が示されています(M3 のみ)が、タブのバックスタックを末尾に戻すような振る舞いを実装することも可能です。

タブの再選択を検知するには BottomNavigationView#setOnItemReselectedListener を使います。次のコード例では、タブの再選択時にそのタブのバックスタックの末尾まで戻る処理を実行しています。

navView.setOnItemReselectedListener { item ->
  backToBottomNavigationMenuDestination(navController, item.itemId)
}

private fun backToBottomNavigationMenuDestination(
  navController: NavController,
  @IdRes id: Int,
) {
  val node = navController.currentDestination?.parent?.findNode(id) ?: return
  // 現在表示している Destination の親が NavGraph でないときは何もしない
  if (node !is NavGraph) {
    return
  }
  // 現在表示している Destination と NavGraph の Start Destination が一致するので何もしなくてよい
  if (node.startDestinationId == navController.currentDestination?.id) {
    return
  }
  navController.popBackStack(node.startDestinationId, inclusive = false)
}

スクロール可能なコンテンツを表示している画面(今回の例では Fragment)でタブの再選択時に一番上までスクロールさせる場合は、次のように一番上までスクロール可能であることを示すインタフェースを用いて実装します。

interface Scrollable {
  fun scrollToTop()
}

// Jetpack Navigation で管理しているバックスタックのなかで現在表示中の Fragment を取り出す
fun Activity.findPrimaryFragmentInNavHost(): Fragment? {
  if (this !is FragmentActivity) {
    return null
  }
  return supportFragmentManager.primaryNavigationFragment?.let { fragment ->
    fragment.childFragmentManager.fragments.first { it.isVisible }
  }
}

navView.setOnItemReselectedListener { item ->
  activity.findPrimaryFragmentInNavHost()?.let { fragment ->
    if (fragment is Scrollable) {
      fragment.scrollToTop()
    }
  }
}

個別の画面からタブに対応する画面に遷移する

BottomNavigationView のタブの切り替えはタブの選択や DeepLink 以外にも発生します。たとえば、次のような手順でアプリを操作すると、BottomNavigationView の操作以外でもタブが切り替わります。

アプリ起動 -> ホーム画面を開く -> ホーム画面内のボタンでダッシュボード画面へ遷移する -> ダッシュボードタブが選択状態になる

このとき、画面遷移の処理を次のように実装していると、ホームタブを選択して戻ろうとしたときにホーム画面に戻れなくなります。BottomNavigationView のタブの選択状態は変わっても、肝心の画面はダッシュボード画面のままです。

class HomeFragment : Fragment() {
  override fun onCreateView(
    inflater: LayoutInflater,
    container: ViewGroup?,
    savedInstanceState: Bundle?
  ): View = ComposeView(requireContext()).apply {
    setContent {
      MaterialTheme {
        HomeScreen(
          onClickOpenDashboard = {
            findNavController().navigate(R.id.tab_dashboard)
          },
        )
      }
    }
  }
}

これは Multiple Back Stacks におけるバックスタックの保存に関する処理が足りず、戻ろうとしたときにバックスタックの復帰ができなくなるためです。

次のように NavOptions を使って NavController#navigate を呼び出すと、Multiple Back Stacks の動作に適合しホームタブに戻れるようになります。

class HomeFragment : Fragment() {
  override fun onCreateView(
    inflater: LayoutInflater,
    container: ViewGroup?,
    savedInstanceState: Bundle?
  ): View = ComposeView(requireContext()).apply {
    setContent {
      MaterialTheme {
        HomeScreen(
          onClickOpenDashboard = {
            findNavController().let {
            val builder = NavOptions.Builder()
              .setLaunchSingleTop(true)
              .setRestoreState(true)
              .setPopUpTo(
                it.graph.findStartDestination().id,
                inclusive = false,
                saveState = true,
              )
              it.navigate(R.id.tab_dashboard, null, builder.build())
            }
          },
        )
      }
    }
  }
}

ただ、この手順は NavigationUI.onNavDestinationSelected の処理とほぼ同等で、NavigationUI.onNavDestinationSelected ではタブ切り替え時のフェードイン・フェードアウトのアニメーションも追加してくれる(このアニメーションは Material Design のガイドラインに準拠するもの)ため、可能ならば個別の画面内でタブ切り替えの発生する画面遷移は NavigationUI.onNavDestinationSelected を使うほうが、 BottomNavigationView のタブ切り替え時と全く同じ処理が実行でき、考慮することが減ってよさそうです。

次のコードは NavigationUI.onNavDestinationSelected を使う例を示します。NavigationUI.onNavDestinationSelected を使うにはどの MenuItem を選択したかを渡す必要があるため、一度 ViewModel を介して Activity 側で処理を実行することになります。 また現在選択状態になっているタブに対応する Destination ID へ遷移する場合も考えられるため、選択状態のタブへ戻るケースでは「BottomNavigationView のタブの再選択の振る舞いを実装する」で紹介したバックスタックの末尾に戻る処理を呼び出し、それ以外は NavigationUI.onNavDestinationSelected を呼び出しています。

// MainActivity がもつ BottomNavigationView のタブ切り替えを Fragment から実行するための Facade を提供する
// Navigation 用の ViewModel
class MainNavigationViewModel : ViewModel() {
  private val navigationIdMutation: MutableSharedFlow<Int> = MutableSharedFlow(replay = 1)
  val navigationIdFlow: SharedFlow<Int> = navigationIdMutation.asSharedFlow()

  fun openDashboard() {
    navigationIdMutation.tryEmit(R.id.tab_dashboard)
  }
}

class HomeFragment : Fragment() {
  private val navigationViewModel: MainNavigationViewModel by activityViewModels()

  override fun onCreateView(
    inflater: LayoutInflater,
    container: ViewGroup?,
    savedInstanceState: Bundle?
  ): View = ComposeView(requireContext()).apply {
    setContent {
      MaterialTheme {
        HomeScreen(
          onClickOpenDashboard = {
            navigationViewModel.openDashboard()
          },
        )
      }
    }
  }
}

class MainActivity : AppCompatActivity() {
  private lateinit var binding: ActivityMainBinding
  private val mainNavigationViewModel: MainNavigationViewModel by viewModels()

  override fun onCreate(savedInstanceState: Bundle?) {
    // ...

    val navView: BottomNavigationView = binding.navView
    val fragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment_activity_main) as NavHostFragment
    val navController = fragment.navController

    mainNavigationViewModel.navigationIdFlow
      .onEach { navId ->
        if (navView.selectedItemId == navId) {
          backToBottomNavigationMenuDestination(navController, navId)
        } else {
          NavigationUI.onNavDestinationSelected(navView.menu.findItem(navId), navController)
        }
      }.launchIn(lifecycleScope)
  }
}

おわりに

今回は BottomNavigationView と Jetpack Navigation を組み合わせた画面遷移の実装について、ライブラリの使い方やライブラリのアップデートに伴う変更について紹介しました。

Multiple Back Stacks そのものは Jetpack Navigation ライブラリのアップデートによって取り入れることができますが、AndroidStudio のプロジェクトテンプレートからアプリプロジェクトを作っていたり、Multiple Back Stacks が登場する以前から Jetpack Navigation を使ったアプリを作っていたりする場合、Navigation Graph の構成や画面遷移の仕方自体を見直さないと片手落ちの状態になって様々な不具合を引き起こしてしまいます。

いずれ全てのコードを Jetpack Compose に移行できればこのような工夫はなくなるのかもしれませんが、Fragment をベースとしたアプリケーションを Fragment ごとに Jetpack Compose へ移行していく過渡期にあるプロジェクトでは必要な手順になります。

最終的に、今回紹介した実装を全て組み込むと BottomNavigationView#setupWithNavController の処理はすべて重複または上書きすることになるため、BottomNavigationView#setupWithNavController を使わなくてもよくなります。

今回例にあげたサンプルプロジェクトは GitHub にて公開していますので、合わせて確認してみてください。

github.com


ギフトモールでは、一緒に働く仲間を募集しています!

まずは一度話してみるだけなどのカジュアル面談も歓迎ですので、少しでも興味を持った方はお気軽に連絡ください!

ギフトモールCulture Deck

speakerdeck.com

募集職種一覧

open.talentio.com


  1. 他のアプリから Deep Link を使ってアプリを起動する場合 AndroidManifest に <nav-graph android:value="@navigation/mobile_navigation"/> を合わせて記述します。
  2. https://issuetracker.google.com/issues/228201897#comment2
  3. https://issuetracker.google.com/issues/194301895#comment17