Jetpack Compose Navigation

1. 簡介

上次更新時間:2022 年 7 月 25 日

軟硬體需求

Navigation 是 Jetpack 的程式庫,能讓您從應用程式內的一個目的地導覽至另一個目的地。導覽程式庫也提供了特定成果,方便您透過一致且慣用的方式進行 Jetpack Compose 導覽。這個成果 (navigation-compose) 是本程式碼研究室的關注重點。

執行步驟

您將使用 Rally 質感設計研究做為此程式碼研究室的基礎,進而實作 Jetpack Navigation 元件,並啟用可組合 Rally 畫面之間的導覽功能。

課程內容

  • 使用 Jetpack Navigation 搭配 Jetpack Compose 的基本概念
  • 在可組合項之間進行導覽
  • 將自訂分頁列可組合項整合至導覽階層
  • 使用引數進行導覽
  • 使用深層連結進行導覽
  • 測試導覽

2. 設定

請複製程式碼研究室的起點 (main 分支版本),以便跟著課程操作。

$ git clone https://github.com/googlecodelabs/android-compose-codelabs.git

或者,您也可以下載兩個 ZIP 檔案:

下載程式碼後,請在 Android Studio 中開啟「NavigationCodelab」專案資料夾。您可以開始使用了。

3. Rally 應用程式總覽

首先,您必須熟悉 Rally 應用程式及其程式碼集。執行應用程式,瞭解一下內容。

Rally 有三個主要畫面做為可組合項:

  1. OverviewScreen — 所有財務交易和快訊總覽
  2. AccountsScreen — 現有帳戶的洞察
  3. BillsScreen — 排定的費用

總覽畫面的螢幕截圖,當中包含「快訊」、「帳戶」和「帳單」的相關資訊。 「帳戶」畫面的螢幕截圖,其中包含數個帳戶的資訊。 「帳單」畫面的螢幕截圖,其中包含數個送出帳單的相關資訊。

在螢幕最頂端,Rally 會使用自訂分頁列可組合項 (RallyTabRow),瀏覽這三個畫面。輕觸各圖示之後,目前的選取項目應會展開,並將您導向相應的畫面:

336ba66858ae3728.png e26281a555c5820d.png

前往這些可組合項畫面時,這些畫面也可視為「導覽目的地」,因為我們希望能在特定時間點到達每個目的地。這些目的地在 RallyDestinations.kt 檔案中已預先定義。

在內部可找到全部三個定義為物件的主要目的地 (Overview, AccountsBills),以及一個之後會新增至應用程式的 SingleAccount。每個物件都會從 RallyDestination 介面擴充,且包含每個目的地的必要資訊,方便進行導覽:

  1. 頂端列的 icon
  2. 字串 route (做為 Compose Navigation 前往該目的地的路徑所需)
  3. screen 代表這個目的地的完整可組合項

您執行應用程式時會發現,使用頂端列即可在目前目的地之間導覽。不過,應用程式並未使用 Compose Navigation,實際狀況是目前的導覽機制需要手動切換的可組合項,並觸發重組以利顯示新內容。因此,本程式碼研究室的目標,是要成功遷移並實作 Compose Navigation。

4. 遷移至 Compose Navigation

遷移至 Jetpack Compose 的基本步驟如下:

  1. 新增最新的 Compose Navigation 依附元件
  2. 設定 NavController
  3. 新增 NavHost 並建立導覽圖表
  4. 準備在不同應用程式目的地之間導覽的路線
  5. 以 Compose Navigation 取代目前的導覽機制

以下將逐項詳細說明各個步驟。

新增 Navigation 依附元件

開啟在 app/build.gradle 中的應用程式建構檔案。在依附元件區段中,新增 navigation-compose 依附元件。

dependencies {
  implementation "androidx.navigation:navigation-compose:{latest_version}"
  // ...
}

您可以前往這裡,找到最新版 Navigation Compose。

接著,請同步處理專案,即可開始在 Compose 中使用 Navigation。

設定 NavController

在 Compose 中使用 Navigation 時,NavController 是中心元件。系統會追蹤返回堆疊的可組合項目、往前移動堆疊、啟用返回堆疊操控,以及在目的地狀態之間進行導覽。由於 NavController 是導覽核心,設定 Compose Navigation 時必須先建立好。

呼叫 rememberNavController() 函式即可取得 NavController。這項操作會建立並保留在設定變更後仍然有效的 NavController (使用 rememberSaveable)。

您每次都必須建立 NavController 並放置在可組合元件階層的頂層,通常位於 App 可組合項中。然後,所有需要參照 NavController 的可組合項都要能存取。這種做法符合狀態升降的原則,並確保 NavController 是在不同可組合畫面之間導覽和維護返回堆疊的主要真實來源。

開啟 RallyActivity.kt。在 RallyApp 中使用 rememberNavController() 擷取 NavController,因為它是整個應用程式的根層級可組合項和進入點:

import androidx.navigation.compose.rememberNavController
// ...

@Composable
fun RallyApp() {
    RallyTheme {
        var currentScreen: RallyDestination by remember { mutableStateOf(Overview) }
        val navController = rememberNavController()
        Scaffold(
            // ...
        ) {
            // ...
       }
}

Compose Navigation 中的路徑

如前所述,Rally 應用程式有三個主要目的地,稍後會新增一個目的地 (SingleAccount)。這些項目可以在 RallyDestinations.kt 中定義。我們曾提過,每個目的地都有經過定義的 iconroutescreen

總覽畫面的螢幕截圖,當中包含「快訊」、「帳戶」和「帳單」的相關資訊。 「帳戶」畫面的螢幕截圖,其中包含數個帳戶的資訊。 「帳單」畫面的螢幕截圖,其中包含數個送出帳單的相關資訊。

下一步是將這些目的地新增至導覽圖,而應用程式啟動時則以 Overview 做為起始目的地。

在 Compose 中使用 Navigation 時,導覽圖表中的每個可組合目的地都與一個路徑相關聯。路徑會以「字串」表示,用於定義可組合項目的路徑,並引導 navController 導向正確的位置。您可以當成是導向特定目的地的隱式深層連結。每個目的地都應該有專屬路徑。

為達成這個目的,我們會使用每個 RallyDestination 物件的 route 屬性。舉例來說,Overview.route 是會將您帶往 Overview 螢幕可組合項的路徑。

使用導覽圖呼叫 NavHost 可組合項

下一步是新增 NavHost 並建立導覽圖。

Navigation 的 3 大主要部分是 NavControllerNavGraphNavHostNavController 必定會與單一 NavHost 可組合項相關聯。NavHost 為負責顯示圖表目前目的地的容器。在您瀏覽可組合項時,系統會自動重新組合 NavHost 的內容,也會將 NavController 連結至導覽圖表 (NavGraph),藉此對應可組合的目的地以進行導覽。基本上堪稱可擷取的目的地集合。

返回 RallyActivity.kt 中的 RallyApp 可組合項。將 Scaffold (包含目前的畫面內容,可用來手動切換畫面) 內的 Box 可組合項用新的 NavHost (可按照以下程式碼範例建立) 取代。

傳入我們在上一個步驟中建立的 navController,以便連接至這個 NavHost。如上所述,每個 NavController 都必須與單一 NavHost 建立關聯。

NavHost 也需要 startDestination 路徑,才能知道在應用程式啟動時要顯示哪一個目的地,因此請設為 Overview.route。此外,還要傳遞 Modifier,以便接受外部 Scaffold 邊框間距並套用至 NavHost

最後一個參數 builder: NavGraphBuilder.() -> Unit 負責定義及建構導覽圖。這個參數使用 Navigation Kotlin DSL 的 lambda 語法,因此可在函式內文中以結尾的 lambda 形式傳遞,並從括號中取出:

import androidx.navigation.compose.NavHost
...

Scaffold(...) { innerPadding ->
    NavHost(
        navController = navController,
        startDestination = Overview.route,
        modifier = Modifier.padding(innerPadding)
    ) {
       // builder parameter will be defined here as the graph
    }
}

在 NavGraph 中新增目的地

現在,您可以定義導覽圖和 NavController 可以前往的目的地。如先前所述,builder 參數有預期函式,因此 Navigation Compose 提供 NavGraphBuilder.composable 擴充功能函式,可讓您輕鬆在導覽圖中新增個別可組合目的地,並定義必要的導覽資訊。

第一個目的地是 Overview,因此您必須透過 composable 擴充功能函式新增這個目的地,並設定專屬的字串 route。這項操作只會將目的地新增至導覽圖,因此您也必須定義在導覽至這個目的地時要顯示的實際使用者介面。您也可以利用 composable 函式內文中的結尾 lambda 進行這項操作,這是一種在 Compose 中常用的模式

import androidx.navigation.compose.composable
// ...

NavHost(
    navController = navController,
    startDestination = Overview.route,
    modifier = Modifier.padding(innerPadding)
) {
    composable(route = Overview.route) {
        Overview.screen()
    }
}

根據這個模式,我們會將全部三個主要的螢幕可組合項新增為三個目的地:

NavHost(
    navController = navController,
    startDestination = Overview.route,
    modifier = Modifier.padding(innerPadding)
) {
    composable(route = Overview.route) {
        Overview.screen()
    }
    composable(route = Accounts.route) {
        Accounts.screen()
    }
    composable(route = Bills.route) {
        Bills.screen()
    }
}

接著請執行應用程式,系統會顯示 Overview 做為起始目的地,並顯示對應的使用者介面。

先前提到過自訂頂端分頁列 RallyTabRow 可組合項,該元件先前處理了畫面之間的手動導覽。該元件在這個階段尚未與新版導覽項目連結,因此您可以點選分頁標籤,確認這樣並不會變更顯示畫面可組合項的目的地。接下來讓我們修正這些錯誤!

5. 將 RallyTabRow 與導覽功能整合

在這個步驟中,您將使用 navController 使導覽圖連接 RallyTabRow,以使其導覽至正確的目的地。

為此,您必須使用新的 navController,才能為 RallyTabRowonTabSelected 回呼定義正確的導覽動作。這個回呼定義了選取特定分頁圖示時應執行的動作,並透過 navController.navigate(route). 執行導覽動作。

按照本指南,在 RallyActivity 中找出 RallyTabRow 可組合項及其回呼參數 onTabSelected

我們希望分頁在使用者輕觸後導向特定目的地,因此您也需要知道所選取的確切分頁圖示。幸好,onTabSelected: (RallyDestination) -> Unit 參數已經提供了這一點。您將使用這個資訊和 RallyDestination 路徑來引導 navController,並在選取分頁標籤時呼叫 navController.navigate(newScreen.route)

@Composable
fun RallyApp() {
    RallyTheme {
        var currentScreen: RallyDestination by remember { mutableStateOf(Overview) }
        val navController = rememberNavController()
        Scaffold(
            topBar = {
                RallyTabRow(
                    allScreens = rallyTabRowScreens,
                    // Pass the callback like this,
                    // defining the navigation action when a tab is selected:
                    onTabSelected = { newScreen ->
                        navController.navigate(newScreen.route)
                    },
                    currentScreen = currentScreen,
                )
            }

如果您現在執行應用程式,可以確認輕觸 RallyTabRow 中的個別分頁是否確實前往正確的可組合項目的地。不過,您可能注意到下列兩個問題:

  1. 連續輕觸同一分頁標籤會啟動同一目的地的多個副本
  2. 分頁的使用者介面與顯示的目的地不符,也就是說,所選分頁的展開和收合功能無法正常運作:

336ba66858ae3728.png e26281a555c5820d.png

讓我們解決這兩個問題!

推出單一到達網頁副本

如要修正第一個問題,並確定返回堆疊頂端最多會有一個特定目的地副本,Compose Navigation API 提供 launchSingleTop 旗標,可以傳送至 navController.navigate() 動作,如下所示:

navController.navigate(route) { launchSingleTop = true }

由於希望在整個應用程式的目的地間都能採取相同的做法,因此請將這個旗標複製到所有的 。navigate(...) 呼叫可以將其擷取到 RallyActivity 底部的輔助擴充功能:

import androidx.navigation.NavHostController
// ...

fun NavHostController.navigateSingleTopTo(route: String) =
    this.navigate(route) { launchSingleTop = true }

您現在可以將 navController.navigate(newScreen.route) 呼叫替換為 .navigateSingleTopTo(...)。重新執行應用程式,確認現在在頂端列中點選了多次圖示時,您只會看到單一目的地的副本:

@Composable
fun RallyApp() {
    RallyTheme {
        var currentScreen: RallyDestination by remember { mutableStateOf(Overview) }
        val navController = rememberNavController()
        Scaffold(
            topBar = {
                RallyTabRow(
                    allScreens = rallyTabRowScreens,
                    onTabSelected = { newScreen ->
                        navController
                            .navigateSingleTopTo(newScreen.route)
                    },
                    currentScreen = currentScreen,
                )
            }

控制導覽選項和返回堆疊狀態

除了 launchSingleTop 以外,您還可以透過 NavOptionsBuilder 使用其他旗標來更能控制及自訂導覽行為。RallyTabRow 的運作方式與 BottomNavigation 類似,因此建議您在前往與離開目的地時,思考是否要儲存及還原目的地狀態。舉例來說,如果您捲動至「總覽」底部,然後前往「帳戶」並返回原頁面,您要保留捲動位置嗎?要重新輕觸 RallyTabRow 中的同一個目的地,以重新載入畫面狀態嗎?這些都是有效的問題,應由您應用程式的設計需求決定。

我們會說明在相同 navigateSingleTopTo 擴充功能函式中可使用的其他選項:

  • launchSingleTop = true - 如上所述,這可確保返回堆疊頂端最多只會有特定目的地一個副本
  • 在 Rally 應用程式中,這表示重複輕觸相同的分頁並不會啟動多個相同目的地的複本
  • popUpTo(startDestination) { saveState = true } - 跳轉至圖表的起始目的地,以避免選取分頁時在返回堆疊上建立大型目的地堆疊
  • 在 Rally,這意味著在任何目的地按下返回箭頭後,整個返回堆疊會跳至「總覽」
  • restoreState = true - 決定這個導覽動作是否應還原 PopUpToBuilder.saveStatepopUpToSaveState 屬性先前儲存的任何狀態。請注意,如果先前儲存的到達網頁 ID 並未儲存任何狀態,就不會產生任何影響
  • 在 Rally,這意味著重新輕觸同一個分頁會一直保留先前的資料和使用者狀態,而不會重新載入

您可以為程式碼逐一新增所有選項,逐一執行應用程式,並在新增每個旗標後驗證確切行為。如此一來,您就能在實際運作時查看每個旗標變更導覽和返回堆疊狀態的方式:

import androidx.navigation.NavHostController
import androidx.navigation.NavGraph.Companion.findStartDestination
// ...

fun NavHostController.navigateSingleTopTo(route: String) =
    this.navigate(route) {
        popUpTo(
            this@navigateSingleTopTo.graph.findStartDestination().id
        ) {
            saveState = true
        }
        launchSingleTop = true
        restoreState = true
}

修正分頁使用者介面

在程式碼研究室初期,仍使用手動導覽機制時,RallyTabRow 會使用 currentScreen 變數來決定是否要展開或收合每個分頁。

不過,在完成變更後,currentScreen 將不再更新。因此,在 RallyTabRow 中展開及收合所選分頁已失效。

如要使用「Compose Navigation」重新啟用這個行為,您必須知道每個目前位置顯示的導覽目的地,或者在導覽字詞中,這是目前返回堆疊項目頂端的位置,接著每次變更時更新 RallyTabRow

如要透過 State 格式從返回堆疊取得目前目的地的即時更新資訊,可以使用 navController.currentBackStackEntryAsState(),然後擷取目前的 destination:

import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.compose.runtime.getValue
// ...

@Composable
fun RallyApp() {
    RallyTheme {
        val navController = rememberNavController()

        val currentBackStack by navController.currentBackStackEntryAsState()
        // Fetch your currentDestination:
        val currentDestination = currentBackStack?.destination
        // ...
    }
}

currentBackStack?.destination 回傳 NavDestination. 如要正確更新 currentScreen,再次提醒您,您必須找到符合 NavDestination 的回傳,並為 Rally 的三個主畫面可組合內容之一。您必須決定「目前」顯示的內容,才能將這項資訊傳遞至 RallyTabRow.。如前文所述,每個目的地都有專屬路徑,因此我們可以將這個 String 路徑做為湊合用的 ID,以便執行經過驗證的比較作業,並找出不重複的相符結果。

如要更新 currentScreen,您必須疊代 rallyTabRowScreens 清單才能找出相符的路徑,然後傳回對應的 RallyDestination。Kotlin 提供便利的 .find() 函式:

import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.compose.runtime.getValue
// ...

@Composable
fun RallyApp() {
    RallyTheme {
        val navController = rememberNavController()

        val currentBackStack by navController.currentBackStackEntryAsState()
        val currentDestination = currentBackStack?.destination

        // Change the variable to this and use Overview as a backup screen if this returns null
        val currentScreen = rallyTabRowScreens.find { it.route == currentDestination?.route } ?: Overview
        // ...
    }
}

由於 currentScreen 已傳遞至 RallyTabRow,因此您可以執行應用程式,並驗證分頁列使用者介面是否已據此更新。

6. 從 RallyDestinations 擷取畫面可組合內容

目前,我們使用 RallyDestination 介面中的 screen 屬性和其擴充的螢幕物件,在 NavHost (RallyActivity.kt 中新增可組合的使用者介面:

import com.example.compose.rally.ui.overview.OverviewScreen
// ...

NavHost(
    navController = navController,
    startDestination = Overview.route,
    modifier = Modifier.padding(innerPadding)
) {
    composable(route = Overview.route) {
        Overview.screen()
    }
    // ...
}

不過,本程式碼研究室中的步驟 (例如點擊事件) 需要將其他資訊直接傳送至您的可組合項畫面。在實際工作環境中,傳遞更多資料是需要的。

為達成這個目的而提供的正確且簡潔的方法!直接在 NavHost 導覽圖中加入可組合項,並從 RallyDestination 擷取。之後,RallyDestination 和螢幕物件只會保留與導覽有關的資訊 (例如 iconroute),並且與 Compose 使用者介面相關的任何部分分離。

開啟 RallyDestinations.kt。將每個螢幕的可組合項從 RallyDestination 物件的 screen 參數擷取至 NavHost 中的對應 composable 函式,並取代先前的 .screen() 呼叫,如下所示:

import com.example.compose.rally.ui.accounts.AccountsScreen
import com.example.compose.rally.ui.bills.BillsScreen
import com.example.compose.rally.ui.overview.OverviewScreen
// ...

NavHost(
    navController = navController,
    startDestination = Overview.route,
    modifier = Modifier.padding(innerPadding)
) {
    composable(route = Overview.route) {
        OverviewScreen()
    }
    composable(route = Accounts.route) {
        AccountsScreen()
    }
    composable(route = Bills.route) {
        BillsScreen()
    }
}

在此階段,您可以放心從 RallyDestination 及其物件中移除 screen 參數:

interface RallyDestination {
    val icon: ImageVector
    val route: String
}

/**
 * Rally app navigation destinations
 */
object Overview : RallyDestination {
    override val icon = Icons.Filled.PieChart
    override val route = "overview"
}
// ...

再次執行應用程式,並驗證一切如往常一樣運作。完成這個步驟後,您將能在可組合畫面中設定點擊事件。

啟用 OverviewScreen 的點擊

目前,系統會忽略 OverviewScreen 中的任何點擊事件。也就是說,「帳戶」和「帳單」子區段的「SEE ALL」(全部顯示) 按鈕雖然可點選,但實際上無法帶您前往任何地方。這個步驟的目標是啟用這些點擊事件的導覽功能。

總覽畫面的螢幕錄製,捲動至最終點擊目的地,然後嘗試點選。點擊因為尚未實作而無法運作。

OverviewScreen 可組合項能夠接受多個函式做為回呼,並設為點擊事件,在此情況下,這些事件應該是前往 AccountsScreenBillsScreen 的導覽動作。請將這些導覽回呼傳遞至 onClickSeeAllAccountsonClickSeeAllBills,以前往相關目的地。

開啟 RallyActivity.kt,在 NavHost 中尋找 OverviewScreen,並將 navController.navigateSingleTopTo(...) 傳送至含有對應路徑的兩個導覽回呼:

OverviewScreen(
    onClickSeeAllAccounts = {
        navController.navigateSingleTopTo(Accounts.route)
    },
    onClickSeeAllBills = {
        navController.navigateSingleTopTo(Bills.route)
    }
)

navController 現在已可取得足夠的資訊,例如實際目的地的路徑 ,,方便您點選按鈕前往正確的目的地。若查看 OverviewScreen 的實作,就會看到這些回呼已設為對應的 onClick 參數 :

@Composable
fun OverviewScreen(...) {
    // ...
    AccountsCard(
        onClickSeeAll = onClickSeeAllAccounts,
        onAccountClick = onAccountClick
    )
    // ...
    BillsCard(
        onClickSeeAll = onClickSeeAllBills
    )
}

如前文所述,若將 navController 保持在導覽階層的頂層,並提升至 App 可組合項層級 (而非直接傳遞至 OverviewScreen) 等內容),就能輕鬆地單獨預覽、重複使用及測試 OverviewScreen 可組合項,而不必依賴實際或模擬的 navController 例項。使用傳送回呼作為替代也能讓您快速變更點擊事件!

7. 透過引數前往 SingleAccountScreen

讓我們為 AccountsOverview 螢幕新增一些功能!這些畫面目前會列出幾種不同類型的帳戶,例如「檢查」、「居家儲蓄」等。

2f335ceab09e449a.png 2e78a5e090e3fccb.png

然而,點選這些帳戶類型 (還) 不會有任何作用。讓我們一起解決這個問題!輕觸每種帳戶類型時,我們會想要在新的畫面中顯示完整的帳戶詳細資料。為了達到這個目的,我們需要提供關於 navController 的額外資訊,來瞭解目前點選的帳戶類型。這項作業可透過引數完成。

引數是一項強大的工具,能將一或多個引數傳遞至路徑,讓動態路徑更加動態。可根據不同的引數顯示不同的資訊。

RallyApp 中,將新的 composable 函式新增至現有的 NavHost:,以在圖表中加入新目的地 SingleAccountScreen 來處理這些個別帳戶

import com.example.compose.rally.ui.accounts.SingleAccountScreen
// ...

NavHost(
    navController = navController,
    startDestination = Overview.route,
    modifier = Modifier.padding(innerPadding)
) {
    ...
    composable(route = SingleAccount.route) {
        SingleAccountScreen()
    }
}

設定 SingleAccountScreen 到達目的地

到達 SingleAccountScreen 時,這個目的地需要額外資訊,才能指出開啟時的正確帳戶類型。我們可以使用引數來傳送這類資訊。您必須指明路徑另外需要引數 {account_type}。如果查看 RallyDestination 及其 SingleAccount 物件,您會發現此引數已定義如 accountTypeArg 字串以供使用。

如要在導覽時一併傳遞引數與路徑,請務必按照下列模式一併附加:"route/{argument}"。在您的案例中,如下所示:"${SingleAccount.route}/{${SingleAccount.accountTypeArg}}"。別忘了,$ 符號是用於逸出變數:

import androidx.navigation.NavType
import androidx.navigation.compose.navArgument
// ...

composable(
    route =
        "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}"
) {
    SingleAccountScreen()
}

這可確保在觸發動作後導向 SingleAccountScreen 時,必須一併傳遞 accountTypeArg 引數,否則導覽將無法成功。您可以將此視為需要讓目的地追蹤的簽章或合約,方便其他目的地前往 SingleAccountScreen

第二個步驟是讓 composable 知道其應接受引數。只要定義其 arguments 參數即可。您可以視需要定義任意數量的引數,因為 composable 函式預設會接受引數清單。在此情況下,您只需要新增名為 accountTypeArg 的單一程式碼,然後將其指定為類型 String,即可增添額外的安全性。如果您未明確設定類型,系統將從下列引數的預設值推測類型:

import androidx.navigation.NavType
import androidx.navigation.compose.navArgument
// ...

composable(
    route =
        "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}",
    arguments = listOf(
        navArgument(SingleAccount.accountTypeArg) { type = NavType.StringType }
    )
) {
    SingleAccountScreen()
}

這項功能可以完美運作,您可以選擇這樣保留程式碼。不過,所有目的地專屬資訊均位於 RallyDestinations.kt 及其物件中,因此我們接著會繼續使用相同方法 (與先前對 OverviewAccounts,Bills 採取的做法相同),並將此引數清單移至 SingleAccount:

object SingleAccount : RallyDestination {
    // ...
    override val route = "single_account"
    const val accountTypeArg = "account_type"
    val arguments = listOf(
        navArgument(accountTypeArg) { type = NavType.StringType }
    )
}

使用 SingleAccount.arguments 取代先前的引數,回到 NavHost 對應的 composable。這也能確保 NavHost 盡可能保持簡潔且易於讀取:

composable(
    route = "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}",
    arguments =  SingleAccount.arguments
) {
    SingleAccountScreen()
}

現在您已使用 SingleAccountScreen 的引數定義完整路徑,下一步是確保 accountTypeArg 能進一步向下傳遞至 SingleAccountScreen 可組合項,藉此瞭解應正確顯示哪個帳戶類型。當您查看 SingleAccountScreen 的實作時,您會發現這已經設定完成,且正在等待接受 accountType 參數:

fun SingleAccountScreen(
    accountType: String? = UserData.accounts.first().name
) {
   // ...
}

重點回顧:

  • 您已經確保我們定義了要求引數的路徑,做為上述目的地的信號
  • 您已確保 composable 知道必須接受引數

我們的最後一步是透過實際擷取傳送的引數值。

在 Compose 導覽中,每個 NavHost 可組合函式都可存取目前的 NavBackStackEntry - 這個類別會保留目前路徑中的資訊,並傳遞返回堆疊中項目的引數。您可以使用這個程式碼從 navBackStackEntry 取得必要的 arguments 清單,然後搜尋並擷取所需的引數,將其向下傳遞至可組合項畫面。

在這種情況下,您必須向 navBackStackEntry 要求 accountTypeArg。接著,您必須將其進一步傳遞至 SingleAccountScreen'accountType 參數。

您還可以提供引數的預設值作為預留位置,以防其尚未提供,並且還可預防此極端事件以讓您的程式碼更安全。

您的程式碼現在應如下所示:

NavHost(...) {
    // ...
    composable(
        route =
          "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}",
        arguments = SingleAccount.arguments
    ) { navBackStackEntry ->
        // Retrieve the passed argument
        val accountType =
            navBackStackEntry.arguments?.getString(SingleAccount.accountTypeArg)

        // Pass accountType to SingleAccountScreen
        SingleAccountScreen(accountType)
    }
}

現在,您的 SingleAccountScreen 具有必要資訊,可在您瀏覽時顯示正確的帳戶類型。查看 SingleAccountScreen, 的實作時,您可以發現其已比對已傳遞的 accountTypeUserData 來源,以擷取對應的帳戶詳細資料。

讓我們再次執行一項小幅最佳化工作,並將 "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}" 路徑也移至 RallyDestinations.kt 和其 SingleAccount 物件:

object SingleAccount : RallyDestination {
    // ...
    override val route = "single_account"
    const val accountTypeArg = "account_type"
    val routeWithArgs = "${route}/{${accountTypeArg}}"
    val arguments = listOf(
        navArgument(accountTypeArg) { type = NavType.StringType }
    )
}

再次在對應的 NavHost composable: 中替換

// ...
composable(
    route = SingleAccount.routeWithArgs,
    arguments = SingleAccount.arguments
) {...}

設定帳戶和總覽起始目的地

現在您已定義 SingleAccountScreen 路徑和該路徑要求及接受的引數,並成功導向 SingleAccountScreen,因此需要確認從前一個目的地 (也就是來源目的地) 傳遞相同的 accountTypeArg 引數。

如您所見,其中有兩個面:提供與傳遞引數的起始目的地,以及接受該引數並將其用於顯示正確資訊的到達目的地。兩者都必須明確定義。

舉例來說,如果您在 Accounts 目的地上輕觸「檢查」的帳戶類型,則「帳戶」目的地需要傳送「檢查」字串做為引數,並附加至「Single_account」字串路徑。成功開啟對應的 SingleAccountScreen。其字串路徑看起來會像這樣:"single_account/Checking"

像這樣使用 navController.navigateSingleTopTo(...), 時,您應使用與傳遞的引數完全相同的路徑:

navController.navigateSingleTopTo("${SingleAccount.route}/$accountType")

將此導覽動作回呼傳遞至 OverviewScreenAccountsScreenonAccountClick 參數。請注意,這些參數的預先定義為:onAccountClick: (String) -> Unit (包含 String 作為輸入)。換句話說,當使用者輕觸 OverviewAccount 中的特定帳戶類型時,系統會提供該帳戶類型的字串,可供您輕鬆做為導覽引數使用:

OverviewScreen(
    // ...
    onAccountClick = { accountType ->
        navController
          .navigateSingleTopTo("${SingleAccount.route}/$accountType")
    }
)
// ...

AccountsScreen(
    // ...
    onAccountClick = { accountType ->
        navController
          .navigateSingleTopTo("${SingleAccount.route}/$accountType")
    }
)

如要確保內容清晰易讀,您可以將這個導覽動作擷取至私人輔助擴充功能函式:

import androidx.navigation.NavHostController
// ...
OverviewScreen(
    // ...
    onAccountClick = { accountType ->
        navController.navigateToSingleAccount(accountType)
    }
)

// ...

AccountsScreen(
    // ...
    onAccountClick = { accountType ->
        navController.navigateToSingleAccount(accountType)
    }
)

// ...

private fun NavHostController.navigateToSingleAccount(accountType: String) {
    this.navigateSingleTopTo("${SingleAccount.route}/$accountType")
}

這樣一來,當您執行應用程式時,就能點選各帳戶類型並前往對應的 SingleAccountScreen,查看指定帳戶的資料。

總覽畫面的螢幕錄製,捲動至最終點擊目的地,然後嘗試點選。點擊現在會導向目的地。

8. 啟用深層連結支援

除了新增引數,您也可以新增深層連結,將特定網址、動作和/或 MIME 類型,與可組合元件建立關聯。在 Android 中,深層連結是可讓您直接連往應用程式的特定目的地的連結。Navigation Compose 支援隱含式深層連結。叫用隱含式深層連結時 (例如當使用者點選連結時),Android 就可以將應用程式開啟至對應的目的地。

在本節中,您要新增深層連結,以便透過對應的帳戶類型導向 SingleAccountScreen 可組合項,並讓該深層連結一併對外部應用程式公開。如要重新整理記憶體,這個可組合項的路徑為 "single_account/{account_type}",而這也就是您在深層連結中進行小幅深層連結的相關變更,所需使用的。

根據預設,系統不會啟用向外部應用程式公開深層連結的功能,因此您必須在應用程式的 manifest.xml 檔案中新增 <intent-filter> 元素,這將是第一個步驟。

首先,請將應用程式的深層連結新增至應用程式的 AndroidManifest.xml。您必須透過 <activity> 中的 <intent-filter> 建立新的意圖篩選器,並納入 VIEW 操作以及 BROWSABLEDEFAULT 等類別。

接著,在篩選器中,您需要使用 data 標記加入 scheme (rally - 應用程式名稱) 和 host (single_account - 導向可組合元件的路徑) 以定義精確的深層連結。這會提供 rally://single_account 做為深層連結網址。

請注意,您不需要在 AndroidManifest 中宣告 account_type 引數。這稍後會在 NavHost 可組合函式中附加。

<activity
    android:name=".RallyActivity"
    android:windowSoftInputMode="adjustResize"
    android:label="@string/app_name"
    android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="rally" android:host="single_account" />
    </intent-filter>
</activity>

您現在可以在 RallyActivity 中回應傳入的意圖。

可組合項 SingleAccountScreen 已接受引數,但現在也需要接受新建立的深層連結,在觸發深層連結時啟動這個目的地。

SingleAccountScreen 的可組合函式中,再新增一個參數 deepLinks。與 arguments, 類似,其也接受 navDeepLink 清單,因為您可以定義多個導向同一個目的地的深層連結。傳遞 uriPattern,以符合資訊清單 rally://singleaccountintent-filter 的定義,但這次也會附加其 accountTypeArg 引數:

import androidx.navigation.navDeepLink
// ...

composable(
    route = SingleAccount.routeWithArgs,
    // ...
    deepLinks = listOf(navDeepLink {
        uriPattern = "rally://${SingleAccount.route}/{${SingleAccount.accountTypeArg}}"
    })
)

您知道接下來該怎麼做對吧?將這份清單移至 RallyDestinations SingleAccount:

object SingleAccount : RallyDestination {
    // ...
    val arguments = listOf(
        navArgument(accountTypeArg) { type = NavType.StringType }
    )
    val deepLinks = listOf(
       navDeepLink { uriPattern = "rally://$route/{$accountTypeArg}"}
    )
}

再次在對應的 NavHost 可組合項中替換此變數:

// ...
composable(
    route = SingleAccount.routeWithArgs,
    arguments = SingleAccount.arguments,
    deepLinks = SingleAccount.deepLinks
) {...}

現在您的應用程式和 SingleAccountScreen 已準備就緒,可以處理深層連結。如要測試運作是否正常,請在已連結的模擬器或裝置上重新安裝 Rally,然後開啟指令列並執行下列指令,以便模擬深層連結的啟動程序:

adb shell am start -d "rally://single_account/Checking" -a android.intent.action.VIEW

這樣就能直接進入「檢查中」帳戶,但您也可以驗證該帳戶是否適用於所有帳戶類型。

9. 將 NavHost 解壓縮至 RallyNavHost

現在,您的 NavHost 已完成。不過,為了讓測試更容易,並讓 RallyActivity 更簡潔,請從目前的 RallyApp 可組合函式中擷取目前的 NavHost 和其輔助函式 (例如 navigateToSingleAccount) 至其可組合項函式並且命名為 RallyNavHost

RallyApp 是唯一應直接與 navController 搭配使用的可組合項。如前文所述,所有其他巢狀的可組合項畫面應只會取得導覽回呼,而非 navController 本身。

因此,新的 RallyNavHost 可接受 navControllermodifier 做為 RallyApp 的參數:

@Composable
fun RallyNavHost(
    navController: NavHostController,
    modifier: Modifier = Modifier
) {
    NavHost(
        navController = navController,
        startDestination = Overview.route,
        modifier = modifier
    ) {
        composable(route = Overview.route) {
            OverviewScreen(
                onClickSeeAllAccounts = {
                    navController.navigateSingleTopTo(Accounts.route)
                },
                onClickSeeAllBills = {
                    navController.navigateSingleTopTo(Bills.route)
                },
                onAccountClick = { accountType ->
                   navController.navigateToSingleAccount(accountType)
                }
            )
        }
        composable(route = Accounts.route) {
            AccountsScreen(
                onAccountClick = { accountType ->
                   navController.navigateToSingleAccount(accountType)
                }
            )
        }
        composable(route = Bills.route) {
            BillsScreen()
        }
        composable(
            route = SingleAccount.routeWithArgs,
            arguments = SingleAccount.arguments,
            deepLinks = SingleAccount.deepLinks
        ) { navBackStackEntry ->
            val accountType =
              navBackStackEntry.arguments?.getString(SingleAccount.accountTypeArg)
            SingleAccountScreen(accountType)
        }
    }
}

fun NavHostController.navigateSingleTopTo(route: String) =
    this.navigate(route) { launchSingleTop = true }

private fun NavHostController.navigateToSingleAccount(accountType: String) {
    this.navigateSingleTopTo("${SingleAccount.route}/$accountType")
}

現在,請將新的 RallyNavHost 新增至 RallyApp 並重新執行應用程式,確認一切運作如常:

fun RallyApp() {
    RallyTheme {
    ...
        Scaffold(
        ...
        ) { innerPadding ->
            RallyNavHost(
                navController = navController,
                modifier = Modifier.padding(innerPadding)
            )
        }
     }
}

10. 測試撰寫導覽功能

在此程式碼研究室的一開始,您必須確定不會將 navController 直接傳遞給任何可組合項 (高階應用程式除外),而是將導覽回呼傳遞做為參數。這樣一來,您所有可組合項都能獨立測試,因為這類測試不需要 navController 的執行個體。

建議您一律測試整個 Compose Navigation 機制在應用程式中的運作方式是否符合預期,方法是測試 RallyNavHost 和傳遞至可組合項的導覽動作。這些是本節的主要目標。如要單獨測試個別可組合函式,請務必查看在 Jetpack Compose 中進行測試的程式碼研究室。

如要開始測試,我們必須先新增必要的測試依附元件,因此請回到應用程式的建構檔案 (位於 app/build.gradle)。在測試依附元件區段中,新增 navigation-testing 依附元件。

dependencies {
// ...
  androidTestImplementation "androidx.navigation:navigation-testing:$rootProject.composeNavigationVersion"
  // ...
}

準備 NavigationTest 類別

可以從 Activity 本身分隔出 RallyNavHost 進行測試。

由於這項測試仍在 Android 裝置上執行,您必須建立測試目錄 /app/src/androidTest/java/com/example/compose/rally,然後建立新的測試檔案測試類別並命名為 NavigationTest

首先,如要使用 Compose 測試 API,以及使用 Compose 進行測試和控制可組合項和應用程式,請新增 Compose 測試規則

import androidx.compose.ui.test.junit4.createComposeRule
import org.junit.Rule

class NavigationTest {

    @get:Rule
    val composeTestRule = createComposeRule()

}

撰寫您的第一則測試

建立公開的 rallyNavHost 測試函式,並使用 @Test 加上註解。在此函式中,必須先設定要測試的 Compose 內容。請使用 composeTestRulesetContent 來操作。這裡使用可組合的參數做為內文,方便您在測試環境中編寫 Compose 程式碼及新增可組合項,就像在一般的實際工作環境應用程式中一樣。

setContent, 中,您可以設定目前的測試對象 RallyNavHost,並將新的 navController 執行個體的執行個體傳送給該測試對象。Navigation 測試成果提供了實用的 TestNavHostController。讓我們新增這個步驟:

import androidx.compose.ui.platform.LocalContext
import androidx.navigation.compose.ComposeNavigator
import androidx.navigation.testing.TestNavHostController
import org.junit.Assert.fail
import org.junit.Test
// ...

class NavigationTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    lateinit var navController: TestNavHostController

    @Test
    fun rallyNavHost() {
        composeTestRule.setContent {
            // Creates a TestNavHostController
            navController =
                TestNavHostController(LocalContext.current)
            // Sets a ComposeNavigator to the navController so it can navigate through composables
            navController.navigatorProvider.addNavigator(
                ComposeNavigator()
            )
            RallyNavHost(navController = navController)
        }
        fail()
    }
}

如果您複製了上述程式碼,fail() 呼叫會確保您的測試一直失敗,直到進行實際宣告為止。這個呼叫會做為要您完成此測試的提醒。

如要驗證顯示正確的螢幕可組合項,您可以使用其 contentDescription 並聲明已顯示。在本程式碼研究室中,「Accounts」和「Overview」目的地的 contentDescription 已設定完成,因此可直接用於測試驗證。

首次驗證時,應在 RallyNavHost 首次初始化時檢查「總覽」畫面顯示為第一個目的地。您還應該重新命名測試來反映這一點,請呼叫 rallyNavHost_verifyOverviewStartDestination。方法是將 fail() 呼叫替換為下列內容:

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithContentDescription
// ...

class NavigationTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    lateinit var navController: TestNavHostController

    @Test
    fun rallyNavHost_verifyOverviewStartDestination() {
        composeTestRule.setContent {
            navController =
                TestNavHostController(LocalContext.current)
            navController.navigatorProvider.addNavigator(
                ComposeNavigator()
            )
            RallyNavHost(navController = navController)
        }

        composeTestRule
            .onNodeWithContentDescription("Overview Screen")
            .assertIsDisplayed()
    }
}

再次執行測試,驗證已通過測試。

由於您必須為每個即將執行的測試設定 RallyNavHost,因此您可以將其初始化擷取為加註的 @Before 函式,以避免不必要的重複,並讓測試更加簡潔:

import org.junit.Before
// ...

class NavigationTest {

    @get:Rule
    val composeTestRule = createComposeRule()
    lateinit var navController: TestNavHostController

    @Before
    fun setupRallyNavHost() {
        composeTestRule.setContent {
            navController =
                TestNavHostController(LocalContext.current)
            navController.navigatorProvider.addNavigator(
                ComposeNavigator()
            )
            RallyNavHost(navController = navController)
        }
    }

    @Test
    fun rallyNavHost_verifyOverviewStartDestination() {
        composeTestRule
            .onNodeWithContentDescription("Overview Screen")
            .assertIsDisplayed()
    }
}

您可以透過多種方式測試導覽的實作情形,具體方法如下:點按 UI 元素,然後驗證顯示的目的地,或比較預期路徑和目前路徑。

透過點選使用者介面和過濾 contentDescription 進行測試

如要測試導入應用程式,建議您按一下使用者介面。接下來的文字內容可驗證以下情況,在「Overview」畫面中按一下「Accounts」子區段的「SEE ALL」按鈕會前往「Accounts」目的地:

5a9e82acf7efdd5b.png

您將再次使用 OverviewScreenCard 可組合項中的特定按鈕上的 contentDescription 組,透過 performClick() 模擬點選按鈕,然後確認系統顯示「帳戶」目的地:

import androidx.compose.ui.test.performClick
// ...

@Test
fun rallyNavHost_clickAllAccount_navigatesToAccounts() {
    composeTestRule
        .onNodeWithContentDescription("All Accounts")
        .performClick()

    composeTestRule
        .onNodeWithContentDescription("Accounts Screen")
        .assertIsDisplayed()
}

您可以按照此模式,測試應用程式中其餘的點擊導覽動作。

透過 UI 點擊與路徑比較進行的測試

您也可以使用 navController 來檢查目前的 String (字串) 路徑與預期路徑,藉此檢查宣告。方法是在 UI 上執行點按操作 (與上一節相同),然後使用 navController.currentBackStackEntry?.destination?.route 比較目前路徑與預期路徑。

另外還有一個步驟,那就是務必先捲動至「Overview」畫面的「Bills」子區段,否則測試將會失敗,因為找不到 contentDescription 為「All Bills」的節點:

import androidx.compose.ui.test.performScrollTo
import org.junit.Assert.assertEquals
// ...

@Test
fun rallyNavHost_clickAllBills_navigateToBills() {
    composeTestRule.onNodeWithContentDescription("All Bills")
        .performScrollTo()
        .performClick()

    val route = navController.currentBackStackEntry?.destination?.route
    assertEquals(route, "bills")
}

您可以按照這些模式,透過涵蓋其他導覽路徑、目的地和點擊動作來完成測試類別。請立即執行整組測試,確認所有測試都通過。

11. 恭喜

恭喜,您已經成功完成本程式碼研究室!您可以找出解決方案程式碼,並與自己的解決方案比較。

您已在 Rally 應用程式中新增 Jetpack Compose 導覽機制,並熟悉其重要概念。您已瞭解如何設定可組合項目的地的導覽圖、定義導覽路徑和動作、透過引數將其他資訊傳送至路徑、設定深層連結並測試導覽。

如需更多主題和資訊,例如底部導覽列整合、多模組導覽和巢狀圖,請參考 Android GitHub 存放區現已推出瞭解實作方式。

後續步驟

請參考以下教材,繼續完成 Jetpack Compose 學習路徑

進一步瞭解 Jetpack Navigation:

參考文件