Navigation ComponentでNested Navigation Graphのrouteを指定してpopBackStackしたときの挙動

TL;DR

Navigation ComponentでNested Navigation Graphのrouteを指定してpopBackStackしたときは、inclusiveの値がtrue/falseいずれの場合もNested Navigation Graphから抜けることができる

Nested Navigation Graph

Navigation Componentでは、Nested Navigation Graphによって特定のフローをモジュール化できます。公式ドキュメントの「Navigating with Compose」では、ログインフローをモジュール化する例が記載されています。
Nested Navigation Graphにより、特定のフローの詳細情報を隠蔽し、必要以上にDestinationを公開することなく画面遷移が行えるようになります。

ここで、以下のような画面フローがあるとします。
太枠で囲われたScreen AとScreen Cは、Main/Sub Navigation Graphのstart destinationに対応する画面です。


これを実装すると、例えば以下のように書くことができます。

val navController = rememberNavController()
NavHost(
  navController = navController,
  startDestination = DestinationScreenA,
) {
  // main navigation graph scope
  composable(DestinationScreenA) {
    ScreenA(
      navigateToScreenB = {
        navController.navigate(DestinationScreenB)
      },
    )
  }
  composable(DestinationScreenB) {
    ScreenB(
      navigateToScreenC = {
        navController.navigateToScreenC()
      },
    )
  }
  subNavGraph(
    navController = navController,
    navigateBackToScreenB = {
      navController.popBackStack(
        DestinationScreenB, 
        inclusive = false,
      )
    },
  )
)

private const val DestinationScreenA = "a"
private const val DestinationScreenB = "b"

// other module
public fun NavGraphBuilder.subNavGraph(
  navController: NavController,
  navigateBackToScreenB: () -> Unit,
) {
  navigation(
    route = DestinationSubNavGraphRoute,
    startDestination = DestinationScreenC,
  ) {
    // sub navigation graph scope
    composable(RouteSubNavGraph) {
      ScreenC(
        navigateToScreenD = {
          navController.navigate(DestinationScreenD)
        },
      )
    }
    composable(DestinationScreenD) {
      ScreenD(
        navigateBackToB = navigateBackToScreenB,
      )
    }
  }
}

// expose navigation extensions
public fun NavController.navigateToScreenC() {
  navigate(DestinationScreenC)
}

// encapsulate destinations
private const val RouteSubNavGraph = "sub_nav_graph"
private const val DestinationScreenC = "c"
private const val DestinationScreenD = "d"


また、Sub Navigation Graphのrouteを公開することによって、以下のように書くこともできます。差分のある行の近辺を抜粋しています。

val navController = rememberNavController()
NavHost(
  navController = navController,
  startDestination = DestinationScreenA,
) {
  // main navigation graph scope
  ...
  composable(DestinationScreenB) {
    ScreenB(
      navigateToScreenC = {
-         navController.navigateToScreenC()
+         navController.navigate(RouteSubNavGraph)
      },
    )
  }
  subNavGraph(
    navController = navController,
    navigateBackToScreenB = {
      navController.popBackStack(
-         DestinationScreenB, 
+         RouteSubNavGraph,
        inclusive = false,
      )
    },
  )
)

private const val DestinationScreenA = "a"
private const val DestinationScreenB = "b"

// other module
...

// expose navigation extensions
- public fun NavController.navigateToScreenC() {
-   navigate(DestinationScreenC)
- }

// encapsulate destinations
- private const val RouteSubNavGraph = "sub_nav_graph"
+ public const val RouteSubNavGraph = "sub_nav_graph"
private const val DestinationScreenC = "c"
private const val DestinationScreenD = "d"


このコードにおいて、navigateBackToScreenB内のpopBackStackの第1引数の値にSub Navigation Graphのrouteを指定したときに、第2引数inclusiveの値にtrue/falseのどちらを指定すればいいのか分からず、そのことについて調べた結果がこの記事の内容です。

Back stackの変遷

Screen A -> Screen B -> Screen C -> Screen D -> Screen Bと画面遷移したとき、inclusiveの値がtrue/falseいずれの場合もBack stackは以下のようになっていました。
Back stackの内容は、navController.currentBackStack.collectAsState()をログ出力して確認しています。

1. 起動後: 
[] (空)

2. Screen A表示後: 
[
    NavBackStackEntry(...) destination=ComposeNavGraph(0x0) startDestination={Destination(...) route=a},
    NavBackStackEntry(...) destination=Destination(...) route=a
]

3. Screen A -> Screen Bへの遷移後:
[
    NavBackStackEntry(...) destination=ComposeNavGraph(0x0) startDestination={Destination(...) route=a},
    NavBackStackEntry(...) destination=Destination(...) route=a, 
    NavBackStackEntry(...) destination=Destination(...) route=b
]

4. Screen B -> Screen Cへの遷移後: 
[
    NavBackStackEntry(...) destination=ComposeNavGraph(0x0) startDestination={Destination(...) route=a}, 
    NavBackStackEntry(...) destination=Destination(...) route=a, 
    NavBackStackEntry(...) destination=Destination(...) route=b, 
*   NavBackStackEntry(...) destination=ComposeNavGraph(...) route=sub_nav_graph startDestination={Destination(...) route=c},
    NavBackStackEntry(...) destination=Destination(...) route=c
]

5. Screen C -> Screen Dへの遷移後:
[
    NavBackStackEntry(...) destination=ComposeNavGraph(0x0) startDestination={Destination(...) route=a}, 
    NavBackStackEntry(...) destination=Destination(...) route=a, 
    NavBackStackEntry(...) destination=Destination(...) route=b, 
*   NavBackStackEntry(...) destination=ComposeNavGraph(...) route=sub_nav_graph startDestination={Destination(...) route=c}, 
    NavBackStackEntry(...) destination=Destination(...) route=c, 
    NavBackStackEntry(...) destination=Destination(...) route=d
]

6. Screen D -> Screen Bへの遷移後: 
[
    NavBackStackEntry(...) destination=ComposeNavGraph(0x0) startDestination={Destination(...) route=a}, 
    NavBackStackEntry(...) destination=Destination(...) route=a, 
    NavBackStackEntry(...) destination=Destination(...) route=b
]


注目すべき点は、Nested Navigation Graph自体の情報もBack stackの一部として管理されていることです(*で示したNavBackStackEntry)。そして、*で示したBackStackEntryもroute=sub_nav_graphという情報を持っています。
ここから、Nested Navigation Graphのrouteを指定してpopBackStackすると、本来であればinclusiveの値がtrueであれば*で示したNavBackStackEntryまで、falseであれば*で示したNavBackStackEntryの1つ手前までpopされるであろうと考えられます。

しかしながら、実際の挙動としてはそうなっておらず、inclusiveの値がtrue/falseのいずれの場合も*で示したNavBackStackEntryまでpopされていました
そこで、popBackStackの内部実装を追ってみたところ、以下のロジックがありました。

private fun dispatchOnDestinationChanged(): Boolean {
  // We never want to leave NavGraphs on the top of the stack
  while (!backQueue.isEmpty() && backQueue.last().destination is NavGraph) {
    popEntryFromBackStack(backQueue.last())
  }
  ...
}


このwhile文の中で、backQueueの最後にある要素のdestinationNavGraph型であれば、その要素を追加でpopする操作を行っていました(実際のコード)。
これにより、inclusiveの値がfalseであっても*で示したNavBackStackEntryが追加でpopされ、Nested Navigation Graphから抜けると分かりました。