使用 Compose 切換螢幕

1. 事前準備

到目前為止,您所接觸的應用程式均是由單一螢幕組成。但是,您使用的許多應用程式可能都具備多個螢幕可供瀏覽。舉例來說,「設定」應用程式在多個畫面上提供多個內容頁面。

在當代的 Android 開發作業中,多螢幕應用程式均是使用 Jetpack Navigation 元件建立而成。Navigation Compose 元件可讓您使用宣告式方法,在 Compose 中輕鬆建構多螢幕應用程式,就像建立使用者介面一樣。本程式碼研究室介紹了 Navigation Compose 元件的基本概念、如何讓 AppBar 做出回應,以及如何透過意圖將應用程式的資料傳送至其他應用程式,同時說明了日趨複雜的應用程式中的最佳做法。

必要條件

  • 熟悉 Kotlin 語言,包括函式類型、lambda 和範圍函式
  • 熟悉 Compose 中基本的 Row 和 Column 版面配置

課程內容

  • 建立 NavHost 可組合函式,定義應用程式中的路徑和畫面。
  • 使用 NavHostController 瀏覽不同畫面。
  • 操控返回堆疊,前往上一個畫面。
  • 使用意圖與其他應用程式共用資料。
  • 自訂 AppBar,包括標題和返回按鈕。

建構項目

  • 您將在多螢幕應用程式中導入導覽功能。

軟硬體需求

  • 最新版 Android Studio
  • 需連線至網路來下載範例程式碼

2. 下載範例程式碼

如要開始使用,請先下載範例程式碼:

或者,您也可以複製 GitHub 存放區的程式碼:

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-cupcake.git
$ cd basic-android-kotlin-compose-training-cupcake
$ git checkout starter

如要瀏覽本程式碼研究室的解決方案程式碼,請前往 GitHub 查看。

3. 應用程式逐步操作說明

Cupcake 應用程式與您目前為止所接觸的應用程式略有不同。應用程式不會在一個畫面上顯示所有內容,而是用四個不同畫面顯示,使用者可在訂購杯子蛋糕時瀏覽各個畫面。執行應用程式時,您不會看到任何內容,也無法在這些畫面之間瀏覽,因為導覽元件尚未新增至應用程式的程式碼。不過,您還是可以查看每個畫面的可組合預覽畫面,並與下方的最終應用程式畫面對照。

開啟訂購畫面

第一個畫面向使用者顯示三個按鈕,分別對應到要訂購的杯子蛋糕數量。

在程式碼中,這會以 StartOrderScreen.kt 中的 StartOrderScreen 可組合函式表示。

螢幕含有單欄 (含有圖片和文字),以及三個自訂按鈕,可訂購不同的杯子蛋糕數量。自訂按鈕是由同樣位於 StartOrderScreen.kt 中的 SelectQuantityButton 可組合元件實作。

選擇口味螢幕

選取數量後,應用程式會提示使用者選取杯子蛋糕口味。應用程式使用「圓形按鈕」顯示不同的選項。使用者可以從多種口味中選擇一種。

可選的口味會以字串資源 ID 清單的形式儲存在 data.DataSource.kt 中。

選擇取貨日期螢幕

選擇口味後,應用程式會向使用者顯示一系列圓形按鈕,以選取取貨日期。取貨選項來自 OrderViewModel 中 pickupOptions() 函式傳回的清單。

「Choose Flavor」畫面和「Choose Pickup Date」畫面皆以 SelectOptionScreen.kt 中相同的可組合函式 SelectOptionScreen 表示。為什麼要使用相同的可組合元件?這些螢幕的版面配置完全相同!唯一的差別在於資料,但您可以使用相同的可組合元件來顯示口味和取貨日期螢幕。

訂單匯總螢幕

選取取貨日期後,應用程式會顯示「Order Summary」畫面,方便使用者查看並完成訂單。

這個畫面是由 SummaryScreen.kt 中的 OrderSummaryScreen 可組合函式實作。

版面配置包含 Column,其中包括訂單所有相關資訊、用於小計的 Text 可組合函式,以及可將訂單傳送至其他應用程式或取消訂單並返回第一個畫面的按鈕。

如果使用者選擇將訂單傳送至其他應用程式,Cupcake 應用程式會顯示 Android Sharesheet,內含不同的共用選項。

13bde33712e135a4.png

應用程式目前的狀態會儲存在 data.OrderUiState.kt 中。OrderUiState 資料類別包含多個屬性,用來儲存使用者在各個螢幕中的選項。

應用程式螢幕會顯示在 CupcakeApp 可組合元件中。不過,在入門專案中,應用程式只會顯示第一個螢幕。目前無法瀏覽應用程式的所有螢幕,不過別擔心,在這裡就有!您將瞭解如何定義導覽路徑、設定可導覽至不同螢幕的 NavHost 可組合元件 (亦即目的地),並執行意圖以整合系統 UI 可組合元件,例如共用螢幕,以及讓 AppBar 回應導覽變更。

可重複使用的可組合函式

本課程中的範例應用程式可視情況設計用於實作最佳做法。Cupcake 應用程式也不例外。在 ui.components 檔案包中,您會看到一個名為 CommonUi.kt 的檔案,其中包含 FormattedPriceLabel 可組合元件。應用程式中的多個螢幕會使用這個可組合元件,以將訂單價格設為統一格式。與其使用相同的格式和輔助鍵複製相同的 Text 可組合元件,您可以定義 FormattedPriceLabel 一次,然後視需要重複將它用於其他螢幕。

口味和取貨日期螢幕使用 SelectOptionScreen 可組合元件,而這也可重複使用。這個可組合元件會採用 List<String> 類型的 options 參數,該參數代表要顯示的選項。選項會顯示在 Row 中,包含 RadioButton 可組合元件,以及包含每個字串的 Text 可組合元件。Column 圍繞著整個版面配置,並包含 Text 可組合元件,以顯示格式化價格、取消按鈕,和下一步按鈕。

4. 定義路徑及建立 NavHostController

導覽元件的組成部分

導覽元件包含三個主要部分:

  • NavController:負責在到達網頁之間切換,也就是應用程式中的螢幕。
  • NavGraph:將可組合元件目的地對應到導覽目的地。
  • NavHost:作為容器的可組合元件,用於顯示 NavGraph 的目前目的地。

在本程式碼研究室中,您將專注於 NavController 和 NavHost。在 NavHost 中,您將定義 Cupcake 應用程式的 NavGraph 目的地。

定義應用程式中的目的地路徑

路徑是 Compose 應用程式中導覽的基本概念之一。路徑是對應到目的地的字串。這個概念與網址的概念類似。就像不同的網址對應至網站上的不同網頁時,路徑就是對應到目的地的一個字串,可做為其專屬 ID。到達網頁通常是可向使用者顯示的一個可組合元件或一組可組合元件。Cupcake 應用程式需要起始訂單螢幕、口味螢幕、取貨日期螢幕和訂單匯總螢幕的到達網頁。

應用程式中每個螢幕的數量有限,因此路徑數量有限。您可以使用列舉類別定義應用程式的路徑。Kotlin 中的列舉類別具有名稱屬性,其會傳回具有屬性名稱的字串。

首先,定義 Cupcake 應用程式的四個路徑。

  • Start:從三個按鈕中選取一個設為杯子蛋糕的數量。
  • Flavor:從選項清單中選取口味。
  • Pickup:從選項清單中選取取貨日期。
  • Summary:查看所選項目,然後傳送或取消訂單。

新增列舉類別以定義路徑。

  1. 在 CupcakeScreen.kt 的 CupcakeAppBar 可組合元件上方,新增名為 CupcakeScreen 的列舉類別。
enum class CupcakeScreen() {
    
}
  1. 將四個案例新增至列舉類別:Start、Flavor、Pickup 和 Summary。
enum class CupcakeScreen() {
    Start,
    Flavor,
    Pickup,
    Summary
}

在應用程式中新增 NavHost

NavHost 是根據特定路徑顯示其他可組合元件到達網頁的可組合元件。舉例來說,如果路徑為 Flavor,NavHost 就會顯示螢幕選擇杯子蛋糕口味。如果路線為 Summary,則應用程式會顯示匯總螢幕。

NavHost 的語法與任何其他可組合元件一樣。

fae7688d6dd53de9.png

重要參數有兩個。

  • navController: NavHostController 類別的執行個體 您可以使用這個物件在螢幕之間導覽,例如呼叫 navigate() 方法前往其他目的地。您可以透過從可組合函式呼叫 rememberNavController() 來取得 NavHostController。
  • startDestination:字串路徑,定義了應用程式首次顯示 NavHost 時預設顯示的目的地。如果是 Cupcake 應用程式,這應該是 Start 路徑。

如同其他可組合函式,NavHost 也會使用 modifier 參數。

您必須將 NavHost 新增至 CupcakeScreen.kt 中的 CupcakeApp 可組合元件。首先,您需要參考導覽控制器。您可以在新增的「NavHost」和日後新增的 AppBar 中,使用導覽控制器。因此,您必須在 CupcakeApp() 可組合元件中宣告變數。

  1. 開啟 CupcakeScreen.kt。
  2. 在 Scaffold 的 uiState 變數下方,新增 NavHost 可組合函式。
import androidx.navigation.compose.NavHost

Scaffold(
    ...
) { innerPadding ->
    val uiState by viewModel.uiState.collectAsState()

    NavHost()
}
  1. 為 navController 參數傳入 navController 變數,並為 startDestination 參數傳遞 CupcakeScreen.Start.name。傳遞輔助鍵,該輔助鍵被傳遞至 CupcakeApp() 用於輔助鍵參數。傳入最後一個結尾的空白 lambda 做為最終參數。
import androidx.compose.foundation.layout.padding

NavHost(
    navController = navController,
    startDestination = CupcakeScreen.Start.name,
    modifier = Modifier.padding(innerPadding)
) {

}

處理 NavHost 中的路徑

如同其他可組合元件,NavHost 會擷取其內容的函式類型。

f67974b7fb3f0377.png

在 NavHost 的內容函式中,您可以呼叫 composable() 函式。composable() 函式有兩個必要參數。

  • route:與路徑名稱相對應的字串。可以是任何不重複的字串。您將使用 CupcakeScreen 列舉常數的名稱屬性。
  • content:您可以在這裡按需要呼叫可組合元件,用於為特定路徑顯示。

您應針對這四個路徑分別呼叫一次 composable() 函式。

  1. 呼叫 composable() 函式,傳入 route 的 CupcakeScreen.Start.name。
import androidx.navigation.compose.composable

NavHost(
    navController = navController,
    startDestination = CupcakeScreen.Start.name,
    modifier = Modifier.padding(innerPadding)
) {
    composable(route = CupcakeScreen.Start.name) {
        
    }
}
  1. 在結尾的 lambda 中,呼叫 StartOrderScreen 可組合函式,並傳入 quantityOptions 屬性的 quantityOptions。針對 modifier 傳入 Modifier.fillMaxSize().padding(dimensionResource(R.dimen.padding_medium))。
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.res.dimensionResource
import com.example.cupcake.ui.StartOrderScreen
import com.example.cupcake.data.DataSource

NavHost(
    navController = navController,
    startDestination = CupcakeScreen.Start.name,
    modifier = Modifier.padding(innerPadding)
) {
    composable(route = CupcakeScreen.Start.name) {
        StartOrderScreen(
            quantityOptions = DataSource.quantityOptions,
            modifier = Modifier
                .fillMaxSize()
                .padding(dimensionResource(R.dimen.padding_medium))
        )
    }
}
  1. 在對 composable() 的首次呼叫下方,再次呼叫 composable() 以傳入 route 的 CupcakeScreen.Flavor.name。
composable(route = CupcakeScreen.Flavor.name) {
    
}
  1. 在結尾的 lambda 中,取得 LocalContext.current 的參照,並儲存在名為 context 的變數中。Context 是一個抽象類別,其實作是由 Android 系統提供。這可讓您存取應用程式專屬的資源和類別,以及執行應用程式層級作業 (例如啟動活動等) 的向上呼叫。您可以使用這個變數,從檢視模型的資源 ID 清單中取得字串,藉此顯示口味清單。
import androidx.compose.ui.platform.LocalContext

composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
}
  1. 呼叫 SelectOptionScreen 可組合函式。
composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
    SelectOptionScreen(

    )
}
  1. 使用者選取口味時,必須顯示口味螢幕並更新小計。傳入 uiState.price 用於 subtotal 參數。
composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
    SelectOptionScreen(
        subtotal = uiState.price
    )
}
  1. 口味畫面會從應用程式的字串資源取得口味清單。您可以使用 map() 函式並針對每個口味呼叫 context.resources.getString(id),將資源 ID 清單轉換為字串清單。
import com.example.cupcake.ui.SelectOptionScreen

composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
    SelectOptionScreen(
        subtotal = uiState.price,
        options = DataSource.flavors.map { id -> context.resources.getString(id) }
    )
}
  1. 對於 onSelectionChanged 參數,請傳入在檢視模型上呼叫 setFlavor() 的 lambda 運算式,並傳入 it (傳遞至 onSelectionChanged() 的引數)。如果是 modifier 參數,則請傳入 Modifier.fillMaxHeight().。
import androidx.compose.foundation.layout.fillMaxHeight
import com.example.cupcake.data.DataSource.flavors

composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
    SelectOptionScreen(
        subtotal = uiState.price,
        options = DataSource.flavors.map { id -> context.resources.getString(id) },
        onSelectionChanged = { viewModel.setFlavor(it) },
        modifier = Modifier.fillMaxHeight()
    )
}

取貨日期畫面與口味畫面類似。唯一的差別在於傳入 SelectOptionScreen 可組合函式的資料。

  1. 再次呼叫 composable() 函式,傳入 route 參數的 CupcakeScreen.Pickup.name。
composable(route = CupcakeScreen.Pickup.name) {
    
}
  1. 在結尾的 lambda 中,呼叫 SelectOptionScreen 可組合函式,然後像先前一樣,傳入 subtotal 的 uiState.price。請針對 options 參數傳入 uiState.pickupOptions;如果是 onSelectionChanged 參數,請傳入在 viewModel 上呼叫 setDate() 的 lambda 運算式;對於 modifier 參數,則請傳入 Modifier.fillMaxHeight().。
SelectOptionScreen(
    subtotal = uiState.price,
    options = uiState.pickupOptions,
    onSelectionChanged = { viewModel.setDate(it) },
    modifier = Modifier.fillMaxHeight()
)
  1. 再次呼叫 composable(),傳入 route 的 CupcakeScreen.Summary.name。
composable(route = CupcakeScreen.Summary.name) {
    
}
  1. 在結尾的 lambda 中,呼叫 OrderSummaryScreen() 可組合函式,並傳入 orderUiState 參數的 uiState 變數。對於 modifier 參數,請傳入 Modifier.fillMaxHeight().。
import com.example.cupcake.ui.OrderSummaryScreen

composable(route = CupcakeScreen.Summary.name) {
    OrderSummaryScreen(
        orderUiState = uiState,
        modifier = Modifier.fillMaxHeight()
    )
}

以上就是設定 NavHost 的過程。在下一節中,您會確保應用程式在使用者輕觸每個按鈕時變更路徑,並在螢幕之間瀏覽。

5. 瀏覽不同路徑

既然您已定義路徑並對應至 NavHost 中的可組合元件,接下來就要切換不同螢幕。 NavHostController—呼叫 rememberNavController() 的 navController 屬性—負責在路徑之間導覽。不過請注意,這項屬性是在 CupcakeApp 可組合元件中定義。您必須在應用程式的不同螢幕中使用一種方法存取。

很簡單吧!只需將 navController 做為參數傳遞至每個可組合元件即可。

雖然這個方法有效,但不是建構應用程式的理想方式。使用 NavHost 處理應用程式導覽的好處之一,就是導覽邏輯與個別使用者介面分開。此選項可避免將 navController 做為參數傳遞時產生的一些主要缺點。

  • 導覽邏輯會集中儲存在同一處,方便您輕鬆維護程式碼,從而避免因意外在應用程式中隨意導覽至個別螢幕而產生錯誤。
  • 如果應用程式需要採用不同的板型規格 (例如肖像模式手機、折疊式手機或大螢幕平板電腦),按鈕不一定能觸發導覽功能,視應用程式的版面配置而定。個別螢幕均應獨立,且不必注意應用程式中的其他螢幕。

相反地,我們的做法是將函式類型傳遞給每個可組合元件,以應對使用者點選按鈕時觸發的任何情況。如此一來,composable 及其任何子項可組合元件決定何時呼叫函式。不過,導覽邏輯不會向應用程式中的個別螢幕顯示。所有導覽行為都是在 NavHost 中處理。

將按鈕處理常式新增至 StartOrderScreen

首先請在第一個螢幕中按下其中一個數量按鈕時新增一個呼叫的函式類型參數。此函式會傳遞到 StartOrderScreen 可組合元件中,並負責更新檢視模型並導覽至下一個螢幕。

  1. 開啟 StartOrderScreen.kt。
  2. 在 quantityOptions 參數下方、修飾符參數之前,新增類型為 () -> Unit 的參數 onNextButtonClicked。
@Composable
fun StartOrderScreen(
    quantityOptions: List<Pair<Int, Int>>,
    onNextButtonClicked: () -> Unit,
    modifier: Modifier = Modifier
){
    ...
}
  1. 現在 StartOrderScreen 可組合函式會需要 onNextButtonClicked 的值,因此請找出 StartOrderPreview,並將空白的 lambda 主體傳遞至 onNextButtonClicked 參數。
@Preview
@Composable
fun StartOrderPreview() {
    CupcakeTheme {
        StartOrderScreen(
            quantityOptions = DataSource.quantityOptions,
            onNextButtonClicked = {},
            modifier = Modifier
                .fillMaxSize()
                .padding(dimensionResource(R.dimen.padding_medium))
        )
    }
}

每個按鈕對應的杯子蛋糕數量皆不相同。您需要這項資訊,以便針對 onNextButtonClicked 傳入的函式可據此更新檢視模型。

  1. 修改 onNextButtonClicked 參數的類型,以採用 Int 參數。
onNextButtonClicked: (Int) -> Unit,

如要在呼叫 onNextButtonClicked() 時傳遞 Int,請查看 quantityOptions 參數類型。

類型為 List<Pair<Int, Int>> 或 Pair<Int, Int> 清單。您可能不熟悉 Pair 類型,但顧名思義,這是一組值。Pair 會使用兩個一般類型參數。在本例中,它們都是 Int 類型。

8326701a77706258.png

配對項目中的每個項目都可以由第一個屬性或第二個屬性存取。在 StartOrderScreen 可組合函式的 quantityOptions 參數中,第一個 Int 是每個按鈕要顯示的字串資源 ID。第二個 Int 則是杯子蛋糕的實際數量。

我們會在呼叫 onNextButtonClicked() 函式時傳遞所選組合的第二個屬性。

  1. 針對 SelectQuantityButton 的 onClick 參數找出 lambda 運算式。
quantityOptions.forEach { item ->
    SelectQuantityButton(
        labelResourceId = item.first,
        onClick = {}
    )
}
  1. 在 lambda 運算式中,呼叫 onNextButtonClicked 並傳入 item.second,也就是杯子蛋糕的數量。
quantityOptions.forEach { item ->
    SelectQuantityButton(
        labelResourceId = item.first,
        onClick = { onNextButtonClicked(item.second) }
    )
}

將按鈕處理常式新增至 SelectOptionScreen

  1. 在 SelectOptionScreen.kt 中 SelectOptionScreen 可組合函式的 onSelectionChanged 參數下方,新增類型為 () -> Unit 且名為 onCancelButtonClicked 的參數,預設值為 {}。
@Composable
fun SelectOptionScreen(
    subtotal: String,
    options: List<String>,
    onSelectionChanged: (String) -> Unit = {},
    onCancelButtonClicked: () -> Unit = {},
    modifier: Modifier = Modifier
)
  1. 在 onCancelButtonClicked 參數下方,新增另一個類型為 () -> Unit 且名為 onNextButtonClicked 的參數,預設值為 {}。
@Composable
fun SelectOptionScreen(
    subtotal: String,
    options: List<String>,
    onSelectionChanged: (String) -> Unit = {},
    onCancelButtonClicked: () -> Unit = {},
    onNextButtonClicked: () -> Unit = {},
    modifier: Modifier = Modifier
)
  1. 針對取消按鈕的 onClick 參數,傳入 onCancelButtonClicked。
OutlinedButton(
    modifier = Modifier.weight(1f),
    onClick = onCancelButtonClicked
) {
    Text(stringResource(R.string.cancel))
}
  1. 傳入 onNextButtonClicked 以顯示下一個按鈕的 onClick 參數。
Button(
    modifier = Modifier.weight(1f),
    enabled = selectedValue.isNotEmpty(),
    onClick = onNextButtonClicked
) {
    Text(stringResource(R.string.next))
}

將按鈕處理常式新增至 SummaryScreen

最後,新增按鈕處理常式函式用於匯總螢幕上的取消和傳送按鈕。

  1. 在 SummaryScreen.kt 中的 OrderSummaryScreen 可組合元件中,新增 () -> Unit 類型的 onCancelButtonClicked 參數。
@Composable
fun OrderSummaryScreen(
    orderUiState: OrderUiState,
    onCancelButtonClicked: () -> Unit,
    modifier: Modifier = Modifier
){
    ...
}
  1. 新增類型 (String, String) -> Unit 的其他參數,並將其命名為 onSendButtonClicked。
@Composable
fun OrderSummaryScreen(
    orderUiState: OrderUiState,
    onCancelButtonClicked: () -> Unit,
    onSendButtonClicked: (String, String) -> Unit,
    modifier: Modifier = Modifier
){
    ...
}
  1. OrderSummaryScreen 可組合函式現在需要用到 onSendButtonClicked 和 onCancelButtonClicked 的值。請找出 OrderSummaryPreview,將含有兩個 String 參數的空白 lambda 主體傳遞至 onSendButtonClicked,並將空白的 lambda 主體傳遞至 onCancelButtonClicked 參數。
@Preview
@Composable
fun OrderSummaryPreview() {
   CupcakeTheme {
       OrderSummaryScreen(
           orderUiState = OrderUiState(0, "Test", "Test", "$300.00"),
           onSendButtonClicked = { subject: String, summary: String -> },
           onCancelButtonClicked = {},
           modifier = Modifier.fillMaxHeight()
       )
   }
}
  1. 針對發送按鈕的 onClick 參數傳遞 onSendButtonClicked。傳入 newOrder 和 orderSummary,即先前在 OrderSummaryScreen 中定義的兩個變數。這些字串包含使用者可以與其他應用程式共用的實際資料。
Button(
    modifier = Modifier.fillMaxWidth(),
    onClick = { onSendButtonClicked(newOrder, orderSummary) }
) {
    Text(stringResource(R.string.send))
}
  1. 針對取消按鈕的 onClick 參數傳遞 onCancelButtonClicked。
OutlinedButton(
    modifier = Modifier.fillMaxWidth(),
    onClick = onCancelButtonClicked
) {
    Text(stringResource(R.string.cancel))
}

如要前往其他路徑,只要在 NavHostController 執行個體上呼叫 navigate() 方法即可。

fc8aae3911a6a25d.png

導覽方法會採用單一參數:與 NavHost 中定義的路徑對應的 String。如果路徑符合 NavHost 中對 composable() 的其中一個呼叫,應用程式便會導覽至該畫面。

當使用者按下 Start、Flavor 和 Pickup 畫面上的按鈕時,您會傳入呼叫 navigate() 的函式。

  1. 在 CupcakeScreen.kt 中,找出啟動螢幕的 composable() 呼叫。如果是 onNextButtonClicked 參數,請傳入 lambda 運算式。
StartOrderScreen(
    quantityOptions = DataSource.quantityOptions,
    onNextButtonClicked = {
    }
)

還記得傳遞到此函式的 Int 屬性代表杯子蛋糕的數量嗎?在前往下一個螢幕之前,請先更新檢視模型,讓應用程式顯示正確的小計。

  1. 呼叫 viewModel 上的 setQuantity,並傳入 it。
onNextButtonClicked = {
    viewModel.setQuantity(it)
}
  1. 呼叫 navController 上的 navigate(),並傳入 route 的 CupcakeScreen.Flavor.name。
onNextButtonClicked = {
    viewModel.setQuantity(it)
    navController.navigate(CupcakeScreen.Flavor.name)
}
  1. 針對口味螢幕上的 onNextButtonClicked 參數,只要傳入呼叫 navigate() 的 lambda,即可為 route 傳入 CupcakeScreen.Pickup.name。
composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
    SelectOptionScreen(
        subtotal = uiState.price,
        onNextButtonClicked = { navController.navigate(CupcakeScreen.Pickup.name) },
        options = DataSource.flavors.map { id -> context.resources.getString(id) },
        onSelectionChanged = { viewModel.setFlavor(it) },
        modifier = Modifier.fillMaxHeight()
    )
}
  1. 傳入 onCancelButtonClicked 的空白 lambda,您將在下次執行。
SelectOptionScreen(
    subtotal = uiState.price,
    onNextButtonClicked = { navController.navigate(CupcakeScreen.Pickup.name) },
    onCancelButtonClicked = {},
    options = DataSource.flavors.map { id -> context.resources.getString(id) },
    onSelectionChanged = { viewModel.setFlavor(it) },
    modifier = Modifier.fillMaxHeight()
)
  1. 針對取貨螢幕中的 onNextButtonClicked 參數,請傳入呼叫 navigate() 的 lambda,然後傳入 route 的 CupcakeScreen.Summary.name。
composable(route = CupcakeScreen.Pickup.name) {
    SelectOptionScreen(
        subtotal = uiState.price,
        onNextButtonClicked = { navController.navigate(CupcakeScreen.Summary.name) },
        options = uiState.pickupOptions,
        onSelectionChanged = { viewModel.setDate(it) },
        modifier = Modifier.fillMaxHeight()
    )
}
  1. 再次為 onCancelButtonClicked() 傳入空的 lambda。
SelectOptionScreen(
    subtotal = uiState.price,
    onNextButtonClicked = { navController.navigate(CupcakeScreen.Summary.name) },
    onCancelButtonClicked = {},
    options = uiState.pickupOptions,
    onSelectionChanged = { viewModel.setDate(it) },
    modifier = Modifier.fillMaxHeight()
)
  1. 針對 OrderSummaryScreen,請傳入 onCancelButtonClicked 和 onSendButtonClicked 的空白 lambda。為傳入 onSendButtonClicked 的 subject 和 summary 新增參數,以供您稍後實作。
composable(route = CupcakeScreen.Summary.name) {
    OrderSummaryScreen(
        orderUiState = uiState,
        onCancelButtonClicked = {},
        onSendButtonClicked = { subject: String, summary: String ->

        },
        modifier = Modifier.fillMaxHeight()
    )
}

您現在應該能瀏覽應用程式的每個畫面。請注意,呼叫 navigate() 不僅會變更畫面,畫面也會實際放置在返回堆疊頂端。此外,按下系統返回按鈕後,即可返回上一個畫面。

應用程式會將每個畫面堆疊在上一個畫面的頂端,返回按鈕 (bade5f3ecb71e4a2.png) 則可將它們移除。從底部 startDestination 到最頂端剛顯示的那個畫面,這整個過程的畫面記錄稱為「返回堆疊」。

跳到開始畫面

與系統返回按鈕不同,點選「取消」按鈕並不會返回上一個畫面,而是應從返回堆疊中彈出 (移除) 所有畫面,並返回起始畫面。

呼叫 popBackStack() 方法即可執行此操作。

2f382e5eb319b4b8.png

popBackStack() 方法有兩個必要參數。

  • route:該字串代表要返回的目的地路徑。
  • inclusive:如果布林值為 true,系統會彈出 (移除) 指定路徑。如果是 False,popBackStack() 會移除頂部的所有目的地,但不包括起始目的地,將其留作最頂端的螢幕,並對使用者顯示。

當使用者在任何螢幕上按下取消按鈕時,應用程式會重設檢視模型中的狀態並呼叫 popBackStack()。您可以先實作此方法,在具有取消按鈕的所有三個螢幕中,傳入以用於適當的參數。

  1. 在 CupcakeApp() 函式之後,定義名為 cancelOrderAndNavigateToStart() 的私人函式。
private fun cancelOrderAndNavigateToStart() {
}
  1. 新增兩個參數:OrderViewModel 類型的 viewModel 和 NavHostController 類型的 navController。
private fun cancelOrderAndNavigateToStart(
    viewModel: OrderViewModel,
    navController: NavHostController
) {
}
  1. 在函式主體中,呼叫 viewModel 上的 resetOrder()。
private fun cancelOrderAndNavigateToStart(
    viewModel: OrderViewModel,
    navController: NavHostController
) {
    viewModel.resetOrder()
}
  1. 在 navController 上呼叫 popBackStack(),為 route 傳入 CupcakeScreen.Start.name,為 inclusive 傳入 false。
private fun cancelOrderAndNavigateToStart(
    viewModel: OrderViewModel,
    navController: NavHostController
) {
    viewModel.resetOrder()
    navController.popBackStack(CupcakeScreen.Start.name, inclusive = false)
}
  1. 在 CupcakeApp() 可組合元件中,為兩個 SelectOptionScreen 可組合元件和 OrderSummaryScreen 可組合元件的 onCancelButtonClicked 參數傳入 cancelOrderAndNavigateToStart。
composable(route = CupcakeScreen.Start.name) {
    StartOrderScreen(
        quantityOptions = DataSource.quantityOptions,
        onNextButtonClicked = {
            viewModel.setQuantity(it)
            navController.navigate(CupcakeScreen.Flavor.name)
        },
        modifier = Modifier
            .fillMaxSize()
            .padding(dimensionResource(R.dimen.padding_medium))
    )
}
composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
    SelectOptionScreen(
        subtotal = uiState.price,
        onNextButtonClicked = { navController.navigate(CupcakeScreen.Pickup.name) },
        onCancelButtonClicked = {
            cancelOrderAndNavigateToStart(viewModel, navController)
        },
        options = DataSource.flavors.map { id -> context.resources.getString(id) },
        onSelectionChanged = { viewModel.setFlavor(it) },
        modifier = Modifier.fillMaxHeight()
    )
}
composable(route = CupcakeScreen.Pickup.name) {
    SelectOptionScreen(
        subtotal = uiState.price,
        onNextButtonClicked = { navController.navigate(CupcakeScreen.Summary.name) },
        onCancelButtonClicked = {
            cancelOrderAndNavigateToStart(viewModel, navController)
        },
        options = uiState.pickupOptions,
        onSelectionChanged = { viewModel.setDate(it) },
        modifier = Modifier.fillMaxHeight()
    )
}
composable(route = CupcakeScreen.Summary.name) {
    OrderSummaryScreen(
        orderUiState = uiState,
        onCancelButtonClicked = {
            cancelOrderAndNavigateToStart(viewModel, navController)
        },
        onSendButtonClicked = { subject: String, summary: String ->

        },
        modifier = Modifier.fillMaxHeight()
   )
}
  1. 執行應用程式,然後測試在任一螢幕上按下取消按鈕,讓使用者返回最初的螢幕。

6. 前往其他應用程式

目前為止,您已瞭解如何瀏覽應用程式中的不同畫面,以及如何返回主畫面。只要再一個步驟,就能實作在 Cupcake 應用程式中的導覽作業。在訂單摘要畫面上,使用者可以將訂單傳送至其他應用程式。這個選項會開啟 ShareSheet 使用者介面元件,遮住螢幕底部的部分,並顯示共用選項。

這個 UI 不屬於 Cupcake 應用程式。事實上,這是由 Android 作業系統提供。navController. 不會呼叫系統 UI,例如共用螢幕。請改用名為意圖的項目。

意圖是系統執行一些動作的要求,通常會呈現為新活動。意圖有許多種,建議您參閱說明文件中的完整清單。不過,我們會使用 ACTION_SEND 這個意圖。您可以為這個意圖提供部分資料 (例如字串),並為資料提供適當的共用動作。

設定意圖的基本流程如下:

  1. 建立意圖物件並指定意圖,例如 ACTION_SEND。
  2. 指定要與意圖一起傳送的其他資料類型。對於簡單的文字,您可以使用 "text/plain",但您也可以使用其他類型,例如 "image/*" 或 "video/*"。
  3. 透過呼叫 putExtra() 方法,將任何其他資料傳送至意圖,例如要共用的文字或圖片。此意圖會需要兩個額外項目:EXTRA_SUBJECT 和 EXTRA_TEXT。
  4. 呼叫結構定義的 startActivity() 方法,傳入從意圖建立的活動。

我們會逐步說明如何建立共用動作意圖,但其他意圖類型的建立過程也相同。針對日後的專案,建議您視需要參考特定資料類型的文件和必要額外說明文件。

如要建立意圖將杯子蛋糕訂單傳送至其他應用程式,請完成下列步驟:

  1. 在 CupcakeScreen.kt 中,在 CupcakeApp 可組合元件下方,建立名為 shareOrder() 的私人函式。
private fun shareOrder()
  1. 新增一個參數,名為 context,類型為 Context。
import android.content.Context

private fun shareOrder(context: Context) {
}
  1. 新增兩個 String 參數:subject 和 summary。這些字串會顯示在共用動作工作表中。
private fun shareOrder(context: Context, subject: String, summary: String) {
}
  1. 在函式主體中,建立名為 intent 的意圖,並將 Intent.ACTION_SEND 做為引數傳遞。
import android.content.Intent

val intent = Intent(Intent.ACTION_SEND)

由於只需要設定這個 Intent 物件一次,因此您可以使用在先前的程式碼研究室中學到的 apply() 函式,讓接下來幾行程式碼更加簡潔。

  1. 在新建立的意圖中呼叫 apply(),並傳入 lambda 運算式。
val intent = Intent(Intent.ACTION_SEND).apply {
    
}
  1. 在 lambda 主體中,將類型設為 "text/plain"。由於您是在傳遞至 apply() 的函式中執行此操作,因此不需要參照物件的 ID intent。
val intent = Intent(Intent.ACTION_SEND).apply {
    type = "text/plain"
}
  1. 呼叫 putExtra(),傳入 EXTRA_SUBJECT 的主旨。
val intent = Intent(Intent.ACTION_SEND).apply {
    type = "text/plain"
    putExtra(Intent.EXTRA_SUBJECT, subject)
}
  1. 呼叫 putExtra(),傳入 EXTRA_TEXT 的匯總。
val intent = Intent(Intent.ACTION_SEND).apply {
    type = "text/plain"
    putExtra(Intent.EXTRA_SUBJECT, subject)
    putExtra(Intent.EXTRA_TEXT, summary)
}
  1. 呼叫結構定義的 startActivity() 方法。
context.startActivity(
    
)
  1. 在傳入 startActivity() 的 lambda 中,透過呼叫類別方法 createChooser() 從意圖建立活動。傳遞第一個引數和 new_cupcake_order 字串資源的意圖。
context.startActivity(
    Intent.createChooser(
        intent,
        context.getString(R.string.new_cupcake_order)
    )
)
  1. 在 CupcakeApp 可組合元件中,在呼叫 composable() 以用於 CucpakeScreen.Summary.name 時,取得結構定義物件的參照,以便將其傳遞至 shareOrder() 函式。
composable(route = CupcakeScreen.Summary.name) {
    val context = LocalContext.current

    ...
}
  1. 在 onSendButtonClicked() 的 lambda 主體中,呼叫 shareOrder(),並做為引數傳入 context、subject 和 summary。
onSendButtonClicked = { subject: String, summary: String ->
    shareOrder(context, subject = subject, summary = summary)
}
  1. 執行應用程式並瀏覽螢幕。

當您點選「Send Order to Another App」時,底部功能表應該會顯示共用動作,例如「訊息」和「藍牙」,以及您額外提供的主旨和摘要。

13bde33712e135a4.png

7. 讓應用程式列回應導覽

即使應用程式可正常運作,且可在每個畫面中來回導覽,但本程式碼研究室起初提及的螢幕截圖中仍缺少部分內容。應用程式列不會自動回應導覽。當應用程式前往新的路徑時,標題不會更新,也不會在適當時在標題之前顯示「向上按鈕」。

範例程式碼含有可組合函式,用於管理名為 CupcakeAppBar 的 AppBar。在應用程式中實作導覽功能後,您可以使用返回堆疊中的資訊顯示正確的標題,並視情況顯示「向上」按鈕。CupcakeAppBar 可組合函式應會偵測目前的畫面,妥善更新標題。

  1. 在 CupcakeScreen.kt 的 CupcakeScreen 列舉中,使用 @StringRes 註解新增類型為 Int 的 title 參數。
import androidx.annotation.StringRes

enum class CupcakeScreen(@StringRes val title: Int) {
    Start,
    Flavor,
    Pickup,
    Summary
}
  1. 針對每個列舉情況逐一新增資源值 (對應每個畫面的標題文字)。請分別針對 Start、Flavor、Pickup、Summary 畫面使用 app_name、choose_flavor、choose_pickup_date、order_summary。
enum class CupcakeScreen(@StringRes val title: Int) {
    Start(title = R.string.app_name),
    Flavor(title = R.string.choose_flavor),
    Pickup(title = R.string.choose_pickup_date),
    Summary(title = R.string.order_summary)
}
  1. 在 CupcakeAppBar 可組合函式中,加入 CupcakeScreen 類型的 currentScreen 參數。
fun CupcakeAppBar(
    currentScreen: CupcakeScreen,
    canNavigateBack: Boolean,
    navigateUp: () -> Unit = {},
    modifier: Modifier = Modifier
)
  1. 在 CupcakeAppBar 中,將經過硬式編碼的應用程式名稱替換為目前畫面的標題,方法是將 currentScreen.title 傳入對 TopAppBar 標題參數的 stringResource() 呼叫中。
TopAppBar(
    title = { Text(stringResource(currentScreen.title)) },
    modifier = modifier,
    navigationIcon = {
        if (canNavigateBack) {
            IconButton(onClick = navigateUp) {
                Icon(
                    imageVector = Icons.Filled.ArrowBack,
                    contentDescription = stringResource(R.string.back_button)
                )
            }
        }
    }
)

只有返回堆疊上有可組合函式時,才會顯示「向上」按鈕。如果應用程式在返回堆疊中沒有畫面 (顯示「StartOrderScreen」),就不會顯示「向上」按鈕。如要進行檢查,您需要擁有返回堆疊的參照。

  1. 在 CupcakeApp 可組合函式的 navController 變數下方,建立名為 backStackEntry 的變數,並使用 by 委派功能呼叫 navController 的 currentBackStackEntryAsState() 方法。
import androidx.navigation.compose.currentBackStackEntryAsState

@Composable
fun CupcakeApp(
    viewModel: OrderViewModel = viewModel(),
    navController: NavHostController = rememberNavController()
){

    val backStackEntry by navController.currentBackStackEntryAsState()

    ...
}
  1. 將目前畫面的標題轉換為 CupcakeScreen 的值。在 backStackEntry 變數下方,請使用名為 currentScreen 的 val 建立變數,此結果等同於呼叫 CupcakeScreen 的 valueOf() 類別函式。然後,傳入 backStackEntry 的目的地路徑,使用 elvis 運算子提供預設值 CupcakeScreen.Start.name。
val currentScreen = CupcakeScreen.valueOf(
    backStackEntry?.destination?.route ?: CupcakeScreen.Start.name
)
  1. 將 currentScreen 變數的值傳遞至 CupcakeAppBar 可組合函式中的同名參數。
CupcakeAppBar(
    currentScreen = currentScreen,
    canNavigateBack = false,
    navigateUp = {}
)

只要返回堆疊的目前畫面後方有畫面,「向上」按鈕應該就會顯示。您可以使用布林值運算式,判斷「向上」按鈕是否應顯示。

  1. 如果是 canNavigateBack 參數,請傳入布林運算式,檢查 navController 的 previousBackStackEntry 屬性是否不等於空值。
canNavigateBack = navController.previousBackStackEntry != null,
  1. 如要實際返回上一個螢幕,請呼叫 navController 的 navigateUp() 方法。
navigateUp = { navController.navigateUp() }
  1. 執行應用程式。

請注意,AppBar 標題現在已更新,以反映目前螢幕。當您前往 StartOrderScreen 以外的畫面時,系統應顯示「向上」按鈕,並回到上一個畫面。

3fd023516061f522.gif

8. 取得解決方案程式碼

完成程式碼研究室後,如要下載當中用到的程式碼,您可以使用這些 git 指令:

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-cupcake.git
$ cd basic-android-kotlin-compose-training-cupcake
$ git checkout navigation

另外,您也可以下載存放區為 ZIP 檔案,然後解壓縮並在 Android Studio 中開啟。

如要查看本程式碼實驗室的解決方案程式碼,請前往 GitHub。

9. 摘要

恭喜!您已使用 Jetpack Navigation 元件,從簡單的單螢幕應用程式轉換到複雜的多螢幕應用程式,以跨越多個螢幕。定義路徑、在 NavHost 中處理這些路徑,並使用函式類型參數將導覽邏輯與個別螢幕分開。此外,您還學會了如何使用意圖將資料傳送至其他應用程式,以及自訂應用程式列以回應導覽。在接下來的單元中,您將繼續使用這些技能來處理多個更為複雜的多螢幕應用程式。

瞭解詳情