這次遷移到 Compose 時特別花了時間試用 Compose 的 Navigation component,終於弄清 nested graph 的意義。其實 Compose 的 Navigation component 底層都是跟 XML 版的 Navigation component 一樣,只是底層多了以 route 形式的處理。以往的說明文件在介紹 nested navigation graph 時沒有太具體說明 nested graph 背後的意義,看完之後可能覺得只是用來避免單一 XML 檔過長而拆成不同 sub-graph。但其實在 deep link 時是有特別意義。

接下來會用例子示範 nested graph 在 navigation 時出現的不同效果。首先放上在 NavHost 各頁會用到的 Screen composable function,它會顯示文字和按鈕,用來顯示當前頁面的 route 和按下按鈕時會轉到另一頁。

@Composable
fun Screen(
    name: String,
    onClick: (() -> Unit)? = null,
    secondaryOnClick: (() -> Unit)? = null,
) {
    Column {
        Text(text = name)
        if (onClick != null) {
            Button(onClick = onClick) {
                Text(text = "next screen")
            }
        }
        if (secondaryOnClick != null) {
            Button(onClick = secondaryOnClick) {
                Text(text = "secondary button")
            }
        }
    }
}

接下來就是 navigation graph。

val navController = rememberNavController()
NavHost(
    modifier = modifier,
    navController = navController,
    startDestination = "top",
) {
    composable(route = "top") {
        Screen(
            name = "top",
            onClick = { navController.navigate("partA") },
            secondaryOnClick = { navController.navigate("b3") },
        )
    }
    navigation(startDestination = "a1", route = "partA") {
        composable(route = "a1") {
            Screen(name = "a1") { navController.navigate("a2") }
        }
        composable(route = "a2") {
            Screen(name = "a2") { navController.navigate("a3") }
        }
        composable(route = "a3") {
            Screen(
                name = "a3",
                onClick = { navController.navigate("partB") },
                secondaryOnClick = {
                    val intents = navController.createDeepLink()
                        .addDestination("b3")
                        .createTaskStackBuilder()
                        .intents
                    navController.handleDeepLink(intents.first())
                },
            )
        }
    }
    navigation(startDestination = "b1", route = "partB") {
        composable(route = "b1") { Screen(name = "b1") }
        navigation(startDestination = "b2", route = "partB2") {
            composable(route = "b2") { Screen(name = "b2") }
            composable(route = "b3") { Screen(name = "b3") }
        }
    }
}

先看首頁 top,按 top 的「next screen」按鈕能進入 a1 頁,因為 a1partA sub-graph 的 start destination。其實在這個情況下可以把 onClick lambda 寫成 navController.navigate("a1") 都是一樣效果。如果一直按「next screen」按鈕就會順序進入 a1a2a3b1 頁。按 back 掣就能由 b2 沿路回到 a1,最後結束 Activity(假設 NavHost 是放在 Activity)。

這個看起來沒甚麼特別。之後換成在 top 按「secondary button」鈕能的話就會直接進入 b3 頁,按 back 掣一次就會回到 top

接着在 top 頁一直按「next screen」按鈕直到看到 a3 頁時按「secondary button」按鈕,你會發現它進了 b3 頁。這時你再按 back 掣就會看到它回到 b2b1topa3 頁的 secondaryOnClick 用了 deep link 做轉頁,所以當前的 back stack 會被清空,然後換成適用於 b3 頁的 back stack。而這個 back stack 的生成方法是看 b3 頁所屬的 sub-graph (partB2) 的 start destination (b2),所以 b3 的上一頁是 b2 頁。而 b2 頁的上一頁就是看它所屬的 sub-graph (partB) 的 start destination (b1)。按照同一原理 b2 頁的上一頁就是 top 頁。

如果我們把 topsecondaryOnClick 改為用 deep link 形式進入 a3 頁(即是改成下面的 code),你會看到它的 back stack 是 a3a1top。沒有 a2 是因為 a3 的 sub-graph partA 的 start destination 是 a1。換句話說 a1a2a3 都是平級。

composable(route = "top") {
    Screen(
        name = "top",
        onClick = { navController.navigate("partA") },
        secondaryOnClick = {
            val intents = navController.createDeepLink()
                .addDestination("a3")
                .createTaskStackBuilder()
                .intents
            navController.handleDeepLink(intents.first())
        },
    )
}

所以 sub-graph 的作用是在 deep linking 時控制 back stack,這種寫法是確保 Navigation component 能掌握該頁的上一頁,並且只有一頁能做上一頁(簡單來說是退路)。如果不是 deep linking 的話其實你可以隨便轉去整個 navigation graph 內的任何一頁,只要你知道它的 route 就可以了。另外,deep linking 不一定要配 URI(要在 AndroidManifest.xml 加上 intent-filter 那種),剛才例子的寫法是不用加 intent-filter。如果你要在某個很深層的頁面跳轉去另一個深層的頁面,用這個寫法會比每次特意控制 pop back stack 更加自然。當然,如果你是用 URI 那種 deep linking 的話,sub-graph 就是控制 deep link 開 app 後所造出來的 back stack。