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,內含不同的共用選項。
應用程式目前的狀態會儲存在 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
:查看所選項目,然後傳送或取消訂單。
新增列舉類別以定義路徑。
- 在
CupcakeScreen.kt
的CupcakeAppBar
可組合元件上方,新增名為CupcakeScreen
的列舉類別。
enum class CupcakeScreen() {
}
- 將四個案例新增至列舉類別:
Start
、Flavor
、Pickup
和Summary
。
enum class CupcakeScreen() {
Start,
Flavor,
Pickup,
Summary
}
在應用程式中新增 NavHost
NavHost 是根據特定路徑顯示其他可組合元件到達網頁的可組合元件。舉例來說,如果路徑為 Flavor
,NavHost
就會顯示螢幕選擇杯子蛋糕口味。如果路線為 Summary
,則應用程式會顯示匯總螢幕。
NavHost
的語法與任何其他可組合元件一樣。
重要參數有兩個。
navController
:NavHostController
類別的執行個體 您可以使用這個物件在螢幕之間導覽,例如呼叫navigate()
方法前往其他目的地。您可以透過從可組合函式呼叫rememberNavController()
來取得NavHostController
。startDestination
:字串路徑,定義了應用程式首次顯示NavHost
時預設顯示的目的地。如果是 Cupcake 應用程式,這應該是Start
路徑。
如同其他可組合函式,NavHost
也會使用 modifier
參數。
您必須將 NavHost
新增至 CupcakeScreen.kt
中的 CupcakeApp
可組合元件。首先,您需要參考導覽控制器。您可以在新增的「NavHost
」和日後新增的 AppBar
中,使用導覽控制器。因此,您必須在 CupcakeApp()
可組合元件中宣告變數。
- 開啟
CupcakeScreen.kt
。 - 在
Scaffold
的uiState
變數下方,新增NavHost
可組合函式。
import androidx.navigation.compose.NavHost
Scaffold(
...
) { innerPadding ->
val uiState by viewModel.uiState.collectAsState()
NavHost()
}
- 為
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
會擷取其內容的函式類型。
在 NavHost
的內容函式中,您可以呼叫 composable()
函式。composable()
函式有兩個必要參數。
route
:與路徑名稱相對應的字串。可以是任何不重複的字串。您將使用CupcakeScreen
列舉常數的名稱屬性。content
:您可以在這裡按需要呼叫可組合元件,用於為特定路徑顯示。
您應針對這四個路徑分別呼叫一次 composable()
函式。
- 呼叫
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) {
}
}
- 在結尾的 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))
)
}
}
- 在對
composable()
的首次呼叫下方,再次呼叫composable()
以傳入route
的CupcakeScreen.Flavor.name
。
composable(route = CupcakeScreen.Flavor.name) {
}
- 在結尾的 lambda 中,取得
LocalContext.current
的參照,並儲存在名為context
的變數中。Context
是一個抽象類別,其實作是由 Android 系統提供。這可讓您存取應用程式專屬的資源和類別,以及執行應用程式層級作業 (例如啟動活動等) 的向上呼叫。您可以使用這個變數,從檢視模型的資源 ID 清單中取得字串,藉此顯示口味清單。
import androidx.compose.ui.platform.LocalContext
composable(route = CupcakeScreen.Flavor.name) {
val context = LocalContext.current
}
- 呼叫
SelectOptionScreen
可組合函式。
composable(route = CupcakeScreen.Flavor.name) {
val context = LocalContext.current
SelectOptionScreen(
)
}
- 使用者選取口味時,必須顯示口味螢幕並更新小計。傳入
uiState.price
用於subtotal
參數。
composable(route = CupcakeScreen.Flavor.name) {
val context = LocalContext.current
SelectOptionScreen(
subtotal = uiState.price
)
}
- 口味畫面會從應用程式的字串資源取得口味清單。您可以使用
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) }
)
}
- 對於
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
可組合函式的資料。
- 再次呼叫
composable()
函式,傳入route
參數的CupcakeScreen.Pickup.name
。
composable(route = CupcakeScreen.Pickup.name) {
}
- 在結尾的 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()
)
- 再次呼叫
composable()
,傳入route
的CupcakeScreen.Summary.name
。
composable(route = CupcakeScreen.Summary.name) {
}
- 在結尾的 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
可組合元件中,並負責更新檢視模型並導覽至下一個螢幕。
- 開啟
StartOrderScreen.kt
。 - 在
quantityOptions
參數下方、修飾符參數之前,新增類型為() -> Unit
的參數onNextButtonClicked
。
@Composable
fun StartOrderScreen(
quantityOptions: List<Pair<Int, Int>>,
onNextButtonClicked: () -> Unit,
modifier: Modifier = Modifier
){
...
}
- 現在
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
傳入的函式可據此更新檢視模型。
- 修改
onNextButtonClicked
參數的類型,以採用Int
參數。
onNextButtonClicked: (Int) -> Unit,
如要在呼叫 onNextButtonClicked()
時傳遞 Int
,請查看 quantityOptions
參數類型。
類型為 List<Pair<Int, Int>>
或 Pair<Int, Int>
清單。您可能不熟悉 Pair
類型,但顧名思義,這是一組值。Pair
會使用兩個一般類型參數。在本例中,它們都是 Int
類型。
配對項目中的每個項目都可以由第一個屬性或第二個屬性存取。在 StartOrderScreen
可組合函式的 quantityOptions
參數中,第一個 Int
是每個按鈕要顯示的字串資源 ID。第二個 Int
則是杯子蛋糕的實際數量。
我們會在呼叫 onNextButtonClicked()
函式時傳遞所選組合的第二個屬性。
- 針對
SelectQuantityButton
的onClick
參數找出 lambda 運算式。
quantityOptions.forEach { item ->
SelectQuantityButton(
labelResourceId = item.first,
onClick = {}
)
}
- 在 lambda 運算式中,呼叫
onNextButtonClicked
並傳入item.second
,也就是杯子蛋糕的數量。
quantityOptions.forEach { item ->
SelectQuantityButton(
labelResourceId = item.first,
onClick = { onNextButtonClicked(item.second) }
)
}
將按鈕處理常式新增至 SelectOptionScreen
- 在
SelectOptionScreen.kt
中SelectOptionScreen
可組合函式的onSelectionChanged
參數下方,新增類型為() -> Unit
且名為onCancelButtonClicked
的參數,預設值為{}
。
@Composable
fun SelectOptionScreen(
subtotal: String,
options: List<String>,
onSelectionChanged: (String) -> Unit = {},
onCancelButtonClicked: () -> Unit = {},
modifier: Modifier = Modifier
)
- 在
onCancelButtonClicked
參數下方,新增另一個類型為() -> Unit
且名為onNextButtonClicked
的參數,預設值為{}
。
@Composable
fun SelectOptionScreen(
subtotal: String,
options: List<String>,
onSelectionChanged: (String) -> Unit = {},
onCancelButtonClicked: () -> Unit = {},
onNextButtonClicked: () -> Unit = {},
modifier: Modifier = Modifier
)
- 針對取消按鈕的
onClick
參數,傳入onCancelButtonClicked
。
OutlinedButton(
modifier = Modifier.weight(1f),
onClick = onCancelButtonClicked
) {
Text(stringResource(R.string.cancel))
}
- 傳入
onNextButtonClicked
以顯示下一個按鈕的onClick
參數。
Button(
modifier = Modifier.weight(1f),
enabled = selectedValue.isNotEmpty(),
onClick = onNextButtonClicked
) {
Text(stringResource(R.string.next))
}
將按鈕處理常式新增至 SummaryScreen
最後,新增按鈕處理常式函式用於匯總螢幕上的取消和傳送按鈕。
- 在
SummaryScreen.kt
中的OrderSummaryScreen
可組合元件中,新增() -> Unit
類型的onCancelButtonClicked
參數。
@Composable
fun OrderSummaryScreen(
orderUiState: OrderUiState,
onCancelButtonClicked: () -> Unit,
modifier: Modifier = Modifier
){
...
}
- 新增類型
(String, String) -> Unit
的其他參數,並將其命名為onSendButtonClicked
。
@Composable
fun OrderSummaryScreen(
orderUiState: OrderUiState,
onCancelButtonClicked: () -> Unit,
onSendButtonClicked: (String, String) -> Unit,
modifier: Modifier = Modifier
){
...
}
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()
)
}
}
- 針對發送按鈕的
onClick
參數傳遞onSendButtonClicked
。傳入newOrder
和orderSummary
,即先前在OrderSummaryScreen
中定義的兩個變數。這些字串包含使用者可以與其他應用程式共用的實際資料。
Button(
modifier = Modifier.fillMaxWidth(),
onClick = { onSendButtonClicked(newOrder, orderSummary) }
) {
Text(stringResource(R.string.send))
}
- 針對取消按鈕的
onClick
參數傳遞onCancelButtonClicked
。
OutlinedButton(
modifier = Modifier.fillMaxWidth(),
onClick = onCancelButtonClicked
) {
Text(stringResource(R.string.cancel))
}
導覽至其他路徑
如要前往其他路徑,只要在 NavHostController
執行個體上呼叫 navigate()
方法即可。
導覽方法會採用單一參數:與 NavHost
中定義的路徑對應的 String
。如果路徑符合 NavHost
中對 composable()
的其中一個呼叫,應用程式便會導覽至該畫面。
當使用者按下 Start
、Flavor
和 Pickup
畫面上的按鈕時,您會傳入呼叫 navigate()
的函式。
- 在
CupcakeScreen.kt
中,找出啟動螢幕的composable()
呼叫。如果是onNextButtonClicked
參數,請傳入 lambda 運算式。
StartOrderScreen(
quantityOptions = DataSource.quantityOptions,
onNextButtonClicked = {
}
)
還記得傳遞到此函式的 Int
屬性代表杯子蛋糕的數量嗎?在前往下一個螢幕之前,請先更新檢視模型,讓應用程式顯示正確的小計。
- 呼叫
viewModel
上的setQuantity
,並傳入it
。
onNextButtonClicked = {
viewModel.setQuantity(it)
}
- 呼叫
navController
上的navigate()
,並傳入route
的CupcakeScreen.Flavor.name
。
onNextButtonClicked = {
viewModel.setQuantity(it)
navController.navigate(CupcakeScreen.Flavor.name)
}
- 針對口味螢幕上的
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()
)
}
- 傳入
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()
)
- 針對取貨螢幕中的
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()
)
}
- 再次為
onCancelButtonClicked()
傳入空的 lambda。
SelectOptionScreen(
subtotal = uiState.price,
onNextButtonClicked = { navController.navigate(CupcakeScreen.Summary.name) },
onCancelButtonClicked = {},
options = uiState.pickupOptions,
onSelectionChanged = { viewModel.setDate(it) },
modifier = Modifier.fillMaxHeight()
)
- 針對
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()
不僅會變更畫面,畫面也會實際放置在返回堆疊頂端。此外,按下系統返回按鈕後,即可返回上一個畫面。
應用程式會將每個畫面堆疊在上一個畫面的頂端,返回按鈕 () 則可將它們移除。從底部 startDestination
到最頂端剛顯示的那個畫面,這整個過程的畫面記錄稱為「返回堆疊」。
跳到開始畫面
與系統返回按鈕不同,點選「取消」按鈕並不會返回上一個畫面,而是應從返回堆疊中彈出 (移除) 所有畫面,並返回起始畫面。
呼叫 popBackStack()
方法即可執行此操作。
popBackStack()
方法有兩個必要參數。
route
:該字串代表要返回的目的地路徑。inclusive
:如果布林值為 true,系統會彈出 (移除) 指定路徑。如果是 False,popBackStack()
會移除頂部的所有目的地,但不包括起始目的地,將其留作最頂端的螢幕,並對使用者顯示。
當使用者在任何螢幕上按下取消按鈕時,應用程式會重設檢視模型中的狀態並呼叫 popBackStack()
。您可以先實作此方法,在具有取消按鈕的所有三個螢幕中,傳入以用於適當的參數。
- 在
CupcakeApp()
函式之後,定義名為cancelOrderAndNavigateToStart()
的私人函式。
private fun cancelOrderAndNavigateToStart() {
}
- 新增兩個參數:
OrderViewModel
類型的viewModel
和NavHostController
類型的navController
。
private fun cancelOrderAndNavigateToStart(
viewModel: OrderViewModel,
navController: NavHostController
) {
}
- 在函式主體中,呼叫
viewModel
上的resetOrder()
。
private fun cancelOrderAndNavigateToStart(
viewModel: OrderViewModel,
navController: NavHostController
) {
viewModel.resetOrder()
}
- 在
navController
上呼叫popBackStack()
,為route
傳入CupcakeScreen.Start.name
,為inclusive
傳入false
。
private fun cancelOrderAndNavigateToStart(
viewModel: OrderViewModel,
navController: NavHostController
) {
viewModel.resetOrder()
navController.popBackStack(CupcakeScreen.Start.name, inclusive = false)
}
- 在
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()
)
}
- 執行應用程式,然後測試在任一螢幕上按下取消按鈕,讓使用者返回最初的螢幕。
6. 前往其他應用程式
目前為止,您已瞭解如何瀏覽應用程式中的不同畫面,以及如何返回主畫面。只要再一個步驟,就能實作在 Cupcake 應用程式中的導覽作業。在訂單摘要畫面上,使用者可以將訂單傳送至其他應用程式。這個選項會開啟 ShareSheet 使用者介面元件,遮住螢幕底部的部分,並顯示共用選項。
這個 UI 不屬於 Cupcake 應用程式。事實上,這是由 Android 作業系統提供。navController
. 不會呼叫系統 UI,例如共用螢幕。請改用名為意圖的項目。
意圖是系統執行一些動作的要求,通常會呈現為新活動。意圖有許多種,建議您參閱說明文件中的完整清單。不過,我們會使用 ACTION_SEND
這個意圖。您可以為這個意圖提供部分資料 (例如字串),並為資料提供適當的共用動作。
設定意圖的基本流程如下:
- 建立意圖物件並指定意圖,例如
ACTION_SEND
。 - 指定要與意圖一起傳送的其他資料類型。對於簡單的文字,您可以使用
"text/plain"
,但您也可以使用其他類型,例如"image/*"
或"video/*"
。 - 透過呼叫
putExtra()
方法,將任何其他資料傳送至意圖,例如要共用的文字或圖片。此意圖會需要兩個額外項目:EXTRA_SUBJECT
和EXTRA_TEXT
。 - 呼叫結構定義的
startActivity()
方法,傳入從意圖建立的活動。
我們會逐步說明如何建立共用動作意圖,但其他意圖類型的建立過程也相同。針對日後的專案,建議您視需要參考特定資料類型的文件和必要額外說明文件。
如要建立意圖將杯子蛋糕訂單傳送至其他應用程式,請完成下列步驟:
- 在 CupcakeScreen.kt 中,在
CupcakeApp
可組合元件下方,建立名為shareOrder()
的私人函式。
private fun shareOrder()
- 新增一個參數,名為
context
,類型為Context
。
import android.content.Context
private fun shareOrder(context: Context) {
}
- 新增兩個
String
參數:subject
和summary
。這些字串會顯示在共用動作工作表中。
private fun shareOrder(context: Context, subject: String, summary: String) {
}
- 在函式主體中,建立名為
intent
的意圖,並將Intent.ACTION_SEND
做為引數傳遞。
import android.content.Intent
val intent = Intent(Intent.ACTION_SEND)
由於只需要設定這個 Intent
物件一次,因此您可以使用在先前的程式碼研究室中學到的 apply()
函式,讓接下來幾行程式碼更加簡潔。
- 在新建立的意圖中呼叫
apply()
,並傳入 lambda 運算式。
val intent = Intent(Intent.ACTION_SEND).apply {
}
- 在 lambda 主體中,將類型設為
"text/plain"
。由於您是在傳遞至apply()
的函式中執行此操作,因此不需要參照物件的 IDintent
。
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
}
- 呼叫
putExtra()
,傳入EXTRA_SUBJECT
的主旨。
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_SUBJECT, subject)
}
- 呼叫
putExtra()
,傳入EXTRA_TEXT
的匯總。
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_SUBJECT, subject)
putExtra(Intent.EXTRA_TEXT, summary)
}
- 呼叫結構定義的
startActivity()
方法。
context.startActivity(
)
- 在傳入
startActivity()
的 lambda 中,透過呼叫類別方法createChooser()
從意圖建立活動。傳遞第一個引數和new_cupcake_order
字串資源的意圖。
context.startActivity(
Intent.createChooser(
intent,
context.getString(R.string.new_cupcake_order)
)
)
- 在
CupcakeApp
可組合元件中,在呼叫composable()
以用於CucpakeScreen.Summary.name
時,取得結構定義物件的參照,以便將其傳遞至shareOrder()
函式。
composable(route = CupcakeScreen.Summary.name) {
val context = LocalContext.current
...
}
- 在
onSendButtonClicked()
的 lambda 主體中,呼叫shareOrder()
,並做為引數傳入context
、subject
和summary
。
onSendButtonClicked = { subject: String, summary: String ->
shareOrder(context, subject = subject, summary = summary)
}
- 執行應用程式並瀏覽螢幕。
當您點選「Send Order to Another App」時,底部功能表應該會顯示共用動作,例如「訊息」和「藍牙」,以及您額外提供的主旨和摘要。
7. 讓應用程式列回應導覽
即使應用程式可正常運作,且可在每個畫面中來回導覽,但本程式碼研究室起初提及的螢幕截圖中仍缺少部分內容。應用程式列不會自動回應導覽。當應用程式前往新的路徑時,標題不會更新,也不會在適當時在標題之前顯示「向上按鈕」。
範例程式碼含有可組合函式,用於管理名為 CupcakeAppBar
的 AppBar
。在應用程式中實作導覽功能後,您可以使用返回堆疊中的資訊顯示正確的標題,並視情況顯示「向上」按鈕。CupcakeAppBar
可組合函式應會偵測目前的畫面,妥善更新標題。
- 在 CupcakeScreen.kt 的
CupcakeScreen
列舉中,使用@StringRes
註解新增類型為Int
的title
參數。
import androidx.annotation.StringRes
enum class CupcakeScreen(@StringRes val title: Int) {
Start,
Flavor,
Pickup,
Summary
}
- 針對每個列舉情況逐一新增資源值 (對應每個畫面的標題文字)。請分別針對
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)
}
- 在
CupcakeAppBar
可組合函式中,加入CupcakeScreen
類型的currentScreen
參數。
fun CupcakeAppBar(
currentScreen: CupcakeScreen,
canNavigateBack: Boolean,
navigateUp: () -> Unit = {},
modifier: Modifier = Modifier
)
- 在
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
」),就不會顯示「向上」按鈕。如要進行檢查,您需要擁有返回堆疊的參照。
- 在
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()
...
}
- 將目前畫面的標題轉換為
CupcakeScreen
的值。在backStackEntry
變數下方,請使用名為currentScreen
的val
建立變數,此結果等同於呼叫CupcakeScreen
的valueOf()
類別函式。然後,傳入backStackEntry
的目的地路徑,使用 elvis 運算子提供預設值CupcakeScreen.Start.name
。
val currentScreen = CupcakeScreen.valueOf(
backStackEntry?.destination?.route ?: CupcakeScreen.Start.name
)
- 將
currentScreen
變數的值傳遞至CupcakeAppBar
可組合函式中的同名參數。
CupcakeAppBar(
currentScreen = currentScreen,
canNavigateBack = false,
navigateUp = {}
)
只要返回堆疊的目前畫面後方有畫面,「向上」按鈕應該就會顯示。您可以使用布林值運算式,判斷「向上」按鈕是否應顯示。
- 如果是
canNavigateBack
參數,請傳入布林運算式,檢查navController
的previousBackStackEntry
屬性是否不等於空值。
canNavigateBack = navController.previousBackStackEntry != null,
- 如要實際返回上一個螢幕,請呼叫
navController
的navigateUp()
方法。
navigateUp = { navController.navigateUp() }
- 執行應用程式。
請注意,AppBar
標題現在已更新,以反映目前螢幕。當您前往 StartOrderScreen
以外的畫面時,系統應顯示「向上」按鈕,並回到上一個畫面。
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 中處理這些路徑,並使用函式類型參數將導覽邏輯與個別螢幕分開。此外,您還學會了如何使用意圖將資料傳送至其他應用程式,以及自訂應用程式列以回應導覽。在接下來的單元中,您將繼續使用這些技能來處理多個更為複雜的多螢幕應用程式。