Compose 中的狀態簡介

1. 事前準備

在這個程式碼研究室中,我們會說明一些與狀態相關的知識,以及如何透過 Jetpack Compose 使用及管控狀態。

從核心角度而言,應用程式中的狀態指任何可能隨時間變化的值。這個定義相當廣泛,包含應用程式中從資料庫到變數等一切元素。後續單元將進一步介紹資料庫,但目前您需要瞭解的是,資料庫是經過精心整理的結構化資訊集,例如電腦上的檔案。

所有 Android 應用程式都會向使用者顯示狀態。舉例來說,Android 應用程式可能會顯示以下幾種狀態:

  • 無法建立網路連線時顯示的訊息。
  • 表單,例如註冊表單。狀態可能是「已填寫」和「已提交」。
  • 觸控式控制項,例如按鈕。狀態可能是「未輕觸」、「輕觸中」(顯示動畫) 或「已輕觸」(onClick 動作)。

在本程式碼研究室中,您將探索如何使用 Compose,並思考使用 Compose 時的狀態。為此,您可以使用下列內建的 Compose UI 元素,建構 Tip Time 小費計算機應用程式:

  • 用於輸入及編輯文字的 TextField 可組合函式。
  • 用於顯示文字的 Text 可組合函式。
  • 用於在 UI 元素之間顯示空白空間的 Spacer 可組合函式。

完成這個程式碼研究室時,您就會建構出互動式小費計算機,可在輸入服務金額時自動計算小費金額。最終應用程式的外觀如下圖所示:

e82cbb534872abcf.png

必要條件

  • 瞭解 Compose 的基本知識,例如 @Composable 註解。
  • 熟悉 Compose 版面配置的基本知識,例如 RowColumn 版面配置可組合函式。
  • 熟悉修飾符的基本知識,例如 Modifier.padding() 函式。
  • 熟悉 Text 可組合函式。

課程內容

  • 如何考量 UI 中的狀態。
  • Compose 如何使用狀態顯示資料。
  • 如何在應用程式中加入文字方塊。
  • 如何提升狀態。

建構項目

  • 小費計算機應用程式 Tip Time,可根據服務金額計算小費金額。

軟硬體需求

  • 一台可連上網際網路並具備網路瀏覽器的電腦
  • Kotlin 知識
  • 最新版 Android Studio

2. 立即開始

  1. 請查看 Google 線上小費計算機。請注意,這只是範例,並非您稍後要在本課程中建立的 Android 應用程式。

46bf4366edc1055f.png 18da3c120daa0759.png

  1. 在「Bill」和「Tip %」方塊中輸入不同的值。小費和總金額也會隨之改變。

c0980ba3e9ebba02.png

請注意,當您輸入值時,「Tip」和「Total」會隨之更新。完成下列程式碼研究室之後,您將在 Android 中開發類似的小費計算機應用程式。

在本課程中,您將建立一個簡單的小費計算機 Android 應用程式。

開發人員的工作方式通常如下:先簡單開發一款可以正常使用的應用程式 (即使看起來不甚理想),接著再加入更多功能並提升外觀吸引力。

完成本程式碼研究室之後,您的小費計算機應用程式將如以下螢幕截圖所示。使用者輸入帳單金額時,應用程式就會顯示建議的小費金額。目前,小費百分比是以硬式編碼設為 15%。在下一個程式碼研究室中,您將可以繼續使用應用程式並新增更多功能,例如設定自訂小費百分比。

3. 取得範例程式碼

範例程式碼是預先編寫的程式碼,可用來開始新專案,也可以協助您專注在本程式碼研究室介紹的新概念。

從這裡下載範例程式碼,即可開始操作:

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

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

您可以瀏覽 TipTime GitHub 存放區中的範例程式碼。

範例應用程式總覽

若要瞭解範例程式碼,請完成下列步驟:

  1. 在 Android Studio 中開啟含有範例程式碼的專案。
  2. 在 Android 裝置或模擬器上執行應用程式。
  3. 您會注意到兩個文字元件,一個用於標籤,另一個用於顯示小費金額。

e85b767a43c69a97.png

範例程式碼逐步操作說明

範例程式碼含有文字可組合函式。在本課程中,您將新增文字欄位,方便使用者輸入內容。以下是部分檔案的簡要逐步操作說明,可協助您快速上手。

res > values > strings.xml

<resources>
   <string name="app_name">Tip Time</string>
   <string name="calculate_tip">Calculate Tip</string>
   <string name="bill_amount">Bill Amount</string>
   <string name="tip_amount">Tip Amount: %s</string>
</resources>

這是資源中的 string.xml 檔案,其中包含您將用於此應用程式的所有字串。

MainActivity

此檔案主要包含範本產生的程式碼和下列函式。

  • TipTimeLayout() 函式包含 Column 元素,且具有兩個文字可組合函式,如螢幕截圖所示。此外,這也具有 spacer 可組合函式,可加入空白來提升美感。
  • calculateTip() 函式可接受帳單金額,並計算 15% 小費金額。tipPercent 參數已設為 15.0 預設引數值。這會將預設小費百分比設為 15%。在下一個程式碼研究室中,您將取得使用者提供的小費金額:
@Composable
fun TipTimeLayout() {
    Column(
        modifier = Modifier
            .statusBarsPadding()
            .padding(horizontal = 40.dp)
            .verticalScroll(rememberScrollState())
            .safeDrawingPadding(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text(
            text = stringResource(R.string.calculate_tip),
            modifier = Modifier
                .padding(bottom = 16.dp, top = 40.dp)
                .align(alignment = Alignment.Start)
        )
        Text(
            text = stringResource(R.string.tip_amount, "$0.00"),
            style = MaterialTheme.typography.displaySmall
        )
        Spacer(modifier = Modifier.height(150.dp))
    }
}
private fun calculateTip(amount: Double, tipPercent: Double = 15.0): String {
   val tip = tipPercent / 100 * amount
   return NumberFormat.getCurrencyInstance().format(tip)
}

onCreate() 函式的 Surface() 區塊中,系統會呼叫 TipTimeLayout() 函式,即可在裝置或模擬器中顯示應用程式的版面配置。

override fun onCreate(savedInstanceState: Bundle?) {
   //...
   setContent {
       TipTimeTheme {
           Surface(
           //...
           ) {
               TipTimeLayout()
           }
       }
   }
}

TipTimeLayoutPreview() 函式的 TipTimeTheme 區塊,系統會呼叫 TipTimeLayout() 函式,以便在「Design」和「Split」窗格中顯示應用程式的版面配置。

@Preview(showBackground = true)
@Composable
fun TipTimeLayoutPreview() {
   TipTimeTheme {
       TipTimeLayout()
   }
}

ae11354e61d2a2b9.png

取得使用者輸入內容

在本節中,您可以新增 UI 元素,讓使用者在應用程式中輸入帳單金額。這項 UI 元素的外觀如下圖所示:

58671affa01fb9e1.png

您的應用程式採用自訂樣式和主題。

樣式和主題是一組屬性,可指定單一 UI 元素的外觀。樣式可指定各種屬性,例如字型顏色、字型大小、背景顏色等,也能套用到整個應用程式。後續程式碼研究室將介紹如何在應用程式中實作這些屬性。目前已經替您完成這些實作項目,讓應用程式更美觀。

為協助您進一步瞭解,以下會並排比較加入/未加入自訂主題的應用程式解決方案版本。

未加入自訂主題。

加入自訂主題。

TextField 可組合函式可讓使用者在應用程式內輸入文字。例如,請注意下圖中 Gmail 應用程式登入畫面的文字方塊:

手機畫面顯示 Gmail 應用程式,其中含有電子郵件地址的文字欄位

在應用程式中新增 TextField 可組合函式:

  1. MainActivity.kt 檔案中,新增採用 Modifier 參數的 EditNumberField() 可組合函式。
  2. TipTimeLayout() 下方 EditNumberField() 函式的內文中新增 TextField,用於接受設為空字串的 value 參數,以及設為空白 lambda 運算式的 onValueChange 參數:
@Composable
fun EditNumberField(modifier: Modifier = Modifier) {
   TextField(
      value = "",
      onValueChange = {},
      modifier = modifier
   )
}
  1. 請注意您傳遞的參數:
  • value 參數是文字方塊,會顯示您在這裡傳遞的字串值。
  • onValueChange 參數是 lambda 回呼,會在使用者於文字方塊中輸入文字時觸發。
  1. 匯入這個函式:
import androidx.compose.material3.TextField
  1. TipTimeLayout() 可組合函式中第一個文字可組合函式後方該行,呼叫 EditNumberField() 函式並傳遞下列修飾符。
import androidx.compose.foundation.layout.fillMaxWidth

@Composable
fun TipTimeLayout() {
   Column(
        modifier = Modifier
            .statusBarsPadding()
            .padding(horizontal = 40.dp)
            .verticalScroll(rememberScrollState())
            .safeDrawingPadding(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
   ) {
       Text(
           ...
       )
       EditNumberField(modifier = Modifier.padding(bottom = 32.dp).fillMaxWidth())
       Text(
           ...
       )
       ...
   }
}

畫面上會隨即顯示文字方塊。

  1. 在「Design」窗格中,您應該會看到 Calculate Tip 文字、空白文字方塊和 Tip Amount 文字可組合函式。

2c208378cd4b8d41.png

4. 使用 Compose 中的狀態

應用程式中的狀態是指任何可能隨時間變化的值。在這個應用程式中,狀態是帳單金額。

為儲存狀態,請新增變數:

  1. EditNumberField() 函式的開頭,使用 val 關鍵字新增 amountInput 變數,並設為 "0" 值:
val amountInput = "0"

這是用於帳單金額的應用程式狀態。

  1. value 參數設為 amountInput 值:
TextField(
   value = amountInput,
   onValueChange = {},
)
  1. 查看預覽畫面。文字方塊會顯示設定的狀態變數值,如下圖所示:

e8e24821adfd9d8c.png

  1. 在模擬器中執行應用程式,嘗試輸入不同的值。TextField 可組合函式不會自行更新,因此硬式編碼狀態仍維持不變。當 value 參數變更 (設為 amountInput 屬性) 時,這個可組合函式會隨之更新。

amountInput 變數代表文字方塊的狀態。硬式編碼狀態無法修改,也無法反映使用者輸入內容,因此並不實用。當使用者更新帳單金額時,您需要更新應用程式的狀態。

5. 組成

應用程式中可組合函式描述的 UI 會顯示一欄,當中包含部分文字、一個空格字元和一個文字方塊。該文字會顯示 Calculate Tip 文字,文字方塊則顯示 0 值或任何預設值。

Compose 是宣告式 UI 架構,代表您可以在程式碼中「宣告」UI 的外觀。如果您想讓文字方塊一開始就顯示 100 值,請在程式碼中將可組合函式的初始值設為 100 值。

如果您希望在應用程式執行期間或使用者與應用程式互動時變更 UI,該怎麼做?例如,如果您想將 amountInput 變數更新為使用者輸入的值,並在文字方塊中顯示這個值,該怎麼做?這時,您需要重新組合這個過程來更新應用程式的組成。

「組成」是指 Compose 在執行可組合函式時建構的 UI 描述。Compose 應用程式會呼叫可組合函式,將資料轉換為 UI。如果狀態變更,Compose 會以新的狀態重新執行受影響的可組合函式,進而建立新的 UI,這個過程稱為「重新組成」。Compose 會為您建立「重新組成」排程。

當 Compose 在「初始組成」期間首次執行可組合函式時,系統會追蹤您呼叫的可組合函式,以便在組成內容中描述您的 UI。「重新組成」是指 Compose 重新執行可能因資料變更而發生變化的可組合函式,然後更新組成以反映任何變更。

組成只能由「初始組成」產生,並由「重新組成」更新。修改組成的唯一方式是執行「重新組成」。因此,Compose 必須知道目標追蹤狀態,以便在收到更新時建立重新組成排程。這時,您要追蹤的狀態為 amountInput 變數,因此每當值改變時,Compose 都會建立重新組成排程。

您可以在 Compose 中使用 StateMutableState 類型,以允許 Compose 觀察或追蹤應用程式中的狀態。State 類型不可變動,因此您只能讀取當中的值,而 MutableState 類型可變動。您可以使用 mutableStateOf() 函式建立可觀察的 MutableState。該函式會接收做為參數封裝在 State 物件中的初始值,使 value 變為可觀察狀態。

mutableStateOf() 函式傳回的值:

  • 會保留狀態,也就是帳單金額。
  • 可變動,因此這個值可以變更。
  • 可觀察,也就是 Compose 會觀察這個值發生的任何變更,並觸發重新組成以更新 UI。

新增服務費狀態:

  1. EditNumberField() 函式中,將 amountInput 狀態變數之前的 val 關鍵字變更為 var 關鍵字:
var amountInput = "0"

這會將 amountInput 設為可變動。

  1. 使用 MutableState<String> 類型 (而非硬式編碼的 String 變數),讓 Compose 知道要追蹤 amountInput 狀態並傳入 "0" 字串,也就是 amountInput 狀態變數的初始預設值:
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf

var amountInput: MutableState<String> = mutableStateOf("0")

amountInput 初始化程序也能以類型推論的方式編寫,如下所示:

var amountInput = mutableStateOf("0")

mutableStateOf() 函式會接受初始值 "0" 做為引數,接著這個引數將讓 amountInput 變成可觀察狀態。這會導致 Android Studio 發出以下編譯警告,但您很快就能修正問題:

Creating a state object during composition without using remember.
  1. TextField 可組合函式中,使用 amountInput.value 屬性:
TextField(
   value = amountInput.value,
   onValueChange = {},
   modifier = modifier
)

Compose 會追蹤每個可讀取狀態 value 屬性的可組合函式,並在 value 變更時觸發重新組成。

當文字方塊的輸入內容有所變更時,就會觸發 onValueChange 回呼。在 lambda 運算式中,it 變數包含新值。

  1. onValueChange 命名參數的 lambda 運算式中,將 amountInput.value 屬性設為 it 變數:
@Composable
fun EditNumberField(modifier: Modifier = Modifier) {
   var amountInput = mutableStateOf("0")
   TextField(
       value = amountInput.value,
       onValueChange = { amountInput.value = it },
       modifier = modifier
   )
}

TextField 透過 onValueChange 回呼函式通知您文本發生變更時,您將更新TextField 狀態 (也就是 amountInput 變數)。

  1. 執行應用程式,並在文字方塊中輸入文字。如下圖所示,文字方塊仍然會顯示 0 值:

3a2c62f8ec55e339.gif

使用者在文字方塊中輸入文字時,系統會呼叫 onValueChange 回呼,並將 amountInput 變數更新為新值。amountInput 狀態由 Compose 進行追蹤,因此當其值變更時,Compose 將建立重新組成排程並再次執行 EditNumberField() 可組合函式。在這個可組合函式中,amountInput 變數會重設為初始值 0。因此,文字方塊會顯示 0 值。

在您新增程式碼後,狀態變更會導致系統建立重新組成排程。

不過,您必須設法在重新組成後保留 amountInput 變數的值,以免 EditNumberField() 函式每次重新組成時,該變數值都會重設為 0 值。您將在下一節中解決這個問題。

6. 使用 remember 函式儲存狀態

執行重新組成期間,您可以多次呼叫可組合方法。如果未儲存,這些可組合函式會在重新組成期間重設狀態。

可組合函式可以在重新組成期間使用 remember 儲存物件。remember 函式計算的值會在初始組成期間儲存在「組成」中,並在重新組成時傳回所儲存的值。您通常可以在可組合函式中搭配使用 remembermutableStateOf 函式,使狀態及其更新內容正確反映在 UI 中。

EditNumberField() 函式中,使用 remember 函式:

  1. EditNumberField() 函式中,使用 remember 括住 mutableStateOf() 函式呼叫,以便透過 by remember Kotlin 資源委派初始化 amountInput 變數。
  2. mutableStateOf() 函式中,傳入空字串 (而非靜態 "0" 字串):
var amountInput by remember { mutableStateOf("") }

現在,空字串是 amountInput 變數的初始預設值。byKotlin 資源委派amountInput 屬性的預設 getter 和 setter 函式會分別委派至 remember 類別的 getter 和 setter 函式。

  1. 匯入以下函式:
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue

新增委派的 getter 和 setter 匯入項目後,不必參照 MutableStatevalue 屬性,即可讀取及設定 amountInput

更新後的 EditNumberField() 函式應如下所示:

@Composable
fun EditNumberField(modifier: Modifier = Modifier) {
   var amountInput by remember { mutableStateOf("") }
   TextField(
       value = amountInput,
       onValueChange = { amountInput = it },
       modifier = modifier
   )
}
  1. 執行應用程式,並在文字方塊中輸入一些文字。您現在應該可以看到輸入的文字。

59ac301a208b47c4.png

7. 狀態與重新組成實際使用教學

在本節中,您將設定中斷點並對 EditNumberField() 可組合函式進行偵錯,以便瞭解初始組成和重新組成的運作方式。

設定中斷點,在模擬器或裝置上對應用程式進行偵錯:

  1. onValueChange 命名參數旁邊的 EditNumberField() 函式中,設定行中斷點。
  2. 在導覽選單中,按一下「Debug 'app'」(對應用程式進行偵錯)。應用程式會在模擬器或裝置上啟動。應用程式會在建立 TextField 元素後首次暫停執行。

154e060231439307.png

  1. 在「Debug」(偵錯) 窗格中,按一下「Continue Program」(繼續執行程式) 圖示 2a29a3bad712bec.png。文字方塊建立完成。
  2. 在模擬器或裝置的文字方塊中輸入字母。到達您設定的中斷點時,應用程式就會再次暫停執行程序。

您輸入文字時,系統會呼叫 onValueChange 回呼。在 lambda it 中具有您在鍵盤輸入的新值。

將「it」的值指派給 amountInput 後,由於可觀測值有所變更,Compose 會以新資料觸發重組。

1d5e08d32052d02e.png

  1. 在「Debug」(偵錯) 窗格中,按一下「Continue Program」(繼續執行程式) 圖示 2a29a3bad712bec.png。在模擬器或裝置上輸入的文字會顯示在包含中斷點的程式碼行旁,如下圖所示:

1f5db6ab5ca5b477.png

這就是文字欄位的狀態。

  1. 按一下「Resume Program」圖示 2a29a3bad712bec.png。輸入的值會顯示在模擬器或裝置上。

8. 修改外觀

在上一節中,您已經瞭解文字欄位的運作方式。在本節中,您將提升 UI。

在文字方塊中新增標籤

每個文字方塊都應該設定一個標籤,以便使用者瞭解可以輸入哪些資訊。下方第一張範例圖片顯示,標籤文字位於文字欄位中間,並與輸入行對齊。第二張範例圖片顯示,當使用者按一下文字方塊輸入文字時,標籤會移至文字方塊的上方。如要進一步瞭解文字欄位圖解,請參閱圖解

a2afd6c7fc547b06.png

修改 EditNumberField() 函式,以便在文字欄位中新增標籤:

  1. EditNumberField() 函式的 TextField() 可組合函式中,新增一個設為空白 lambda 運算式的 label 命名參數:
TextField(
//...
   label = { }
)
  1. 在 lambda 運算式中,呼叫用於接受 stringResource(R.string.bill_amount)Text() 函式:
label = { Text(stringResource(R.string.bill_amount)) },
  1. TextField() 可組合函式中新增 singleLine 參數,並將值設為 true
TextField(
  // ...
   singleLine = true,
)

這會將文字方塊從多行壓縮成水平捲動的單行。

  1. 新增 keyboardOptions 命名參數,並設為 KeyboardOptions()
import androidx.compose.foundation.text.KeyboardOptions

TextField(
  // ...
   keyboardOptions = KeyboardOptions(),
)

Android 提供選項讓您設定螢幕上顯示的鍵盤,以便輸入數字、電子郵件地址、網址和密碼等內容。如要進一步瞭解其他鍵盤類型,請參閱 KeyboardType

  1. 將鍵盤類型設為數字鍵盤,即可輸入數字。為 KeyboardOptions 函式傳遞 KeyboardType.Number 命名參數並將值設為 keyboardType
import androidx.compose.ui.text.input.KeyboardType

TextField(
  // ...
   keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
)

完成的 EditNumberField() 函式應如下列程式碼片段所示:

@Composable
fun EditNumberField(modifier: Modifier = Modifier) {
    var amountInput by remember { mutableStateOf("") }
    TextField(
        value = amountInput,
        onValueChange = { amountInput = it },
        singleLine = true,
        label = { Text(stringResource(R.string.bill_amount)) },
        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
        modifier = modifier
    )
}
  1. 執行應用程式。

您可以在此螢幕截圖中查看撥號鍵盤的變化:

55936268bf007ee9.png

9. 顯示小費金額

在本節中,您將實作應用程式的主要功能,也就是計算和顯示小費金額的功能。

MainActivity.kt 檔案中,範例程式碼包含 private calculateTip() 函式。您將使用此函式計算小費金額:

private fun calculateTip(amount: Double, tipPercent: Double = 15.0): String {
    val tip = tipPercent / 100 * amount
    return NumberFormat.getCurrencyInstance().format(tip)
}

在上述方法中,您會使用 NumberFormat 將小費格式顯示為貨幣。

現在應用程式可以計算小費,但您仍需使用類別設定小費格式並顯示小費。

使用 calculateTip() 函式

使用者在文字欄位可組合函式中輸入的文字將做為 String 傳回到 onValueChange 回呼函式,即使使用者輸入的是數字也是如此。為修正這個問題,您需要轉換 amountInput 值,而該值包含使用者輸入的金額。

  1. EditNumberField() 可組合函式中,於 amountInput 定義後方建立名為 amount 的新變數。呼叫 amountInput 變數上的 toDoubleOrNull 函式,將 String 轉換為 Double
val amount = amountInput.toDoubleOrNull()

toDoubleOrNull() 函式是預先定義的 Kotlin 函式,可將字串剖析為 Double 數字並傳回結果;如果字串並非有效數值,則會傳回 null

  1. 請在陳述式結尾新增 ?: Elvis 運算子,以便在 amountInput 為空值時傳回 0.0 值:
val amount = amountInput.toDoubleOrNull() ?: 0.0
  1. amount 變數後,建立另一個名為 tipval 變數。使用 calculateTip() 初始化這個變數,並傳遞 amount 參數。
val tip = calculateTip(amount)

EditNumberField() 函式應如下列程式碼片段所示:

@Composable
fun EditNumberField(modifier: Modifier = Modifier) {
   var amountInput by remember { mutableStateOf("") }

   val amount = amountInput.toDoubleOrNull() ?: 0.0
   val tip = calculateTip(amount)

   TextField(
       value = amountInput,
       onValueChange = { amountInput = it },
       label = { Text(stringResource(R.string.bill_amount)) },
       modifier = Modifier.fillMaxWidth(),
       singleLine = true,
       keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
   )
}

顯示小費金額計算結果

您已編寫計算小費金額的函式,下一步就是顯示小費金額計算結果:

  1. Column() 區塊結尾的 TipTimeLayout() 函式中,請注意顯示 $0.00 的文字可組合函式。您必須將這個值更新為小費金額計算結果。
@Composable
fun TipTimeLayout() {
    Column(
        modifier = Modifier
            .statusBarsPadding()
            .padding(horizontal = 40.dp)
            .verticalScroll(rememberScrollState())
            .safeDrawingPadding(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        // ...
        Text(
            text = stringResource(R.string.tip_amount, "$0.00"),
            style = MaterialTheme.typography.displaySmall
        )
        // ...
    }
}

您需要存取 TipTimeLayout() 函式中的 amountInput 變數,才能計算並顯示小費金額。不過,amountInput 變數是 EditNumberField() 可組合函式中定義的文字欄位狀態,因此您目前無法從 TipTimeLayout() 函式呼叫這個變數。下圖說明程式碼的結構:

50bf0b9d18ede6be.png

這個結構不會讓您在新的 Text 可組合函式中顯示小費金額,因為 Text 可組合函式需要存取使用 amountInput 變數計算的 amount 變數。您必須向 TipTimeLayout() 函式公開 amount 變數。下圖為所需的程式碼結構,可將 EditNumberField() 可組合函式設為無狀態:

ab4ec72388149f7c.png

這個模式稱為「狀態提升」。在下一節中,您會將可組合函式的狀態提升至無狀態

10. 狀態提升

在本節中,您將瞭解如何決定狀態的定義位置,以便重複使用及分享可組合函式。

在可組合函式中,您可以定義一些變數,以保留要在 UI 中顯示的狀態。例如,您可以在 EditNumberField() 可組合函式中將 amountInput 變數定義為狀態。

如果應用程式變得更複雜,而其他可組合函式需要存取 EditNumberField() 可組合函式中的狀態,則您必須考慮從 EditNumberField() 可組合函式中提升或擷取狀態。

瞭解有狀態與無狀態的可組合函式

您必須提升狀態,才能滿足下列需要:

  • 使用多個可組合函式分享狀態。
  • 建立可以在應用程式中重複使用的無狀態可組合函式。

從可組合函式中擷取狀態時,產生的可組合函式稱為無狀態可組合函式。也就是說,您可以從可組合函式中擷取狀態,使可組合函式成為無狀態。

「無狀態」可組合函式不具有狀態,也就是說,這種可組合函式不會保留、定義或修改新狀態。另一方面,「有狀態」可組合函式則具有可隨時間變更的狀態。

狀態提升是將狀態移往呼叫端的模式,可讓元件變成無狀態。

套用至可組合函式時,通常是指將兩個參數加入可組合函式:

  • value: T 參數,這是要顯示的現值。
  • onValueChange: (T) -> Unit - 回呼 lambda,這會在值變更時觸發,方便在其他位置更新狀態,例如使用者在文字方塊中輸入文字時。

使用 EditNumberField() 函式中的狀態:

  1. 更新 EditNumberField() 函式定義,以便透過新增 valueonValueChange 參數來提升狀態:
@Composable
fun EditNumberField(
   value: String,
   onValueChange: (String) -> Unit,
   modifier: Modifier = Modifier
) {
//...

value 參數的類型是 String,而 onValueChange 參數的類型則是 (String) -> Unit,因此這個函式可以接受 String 值做為輸入,且沒有傳回值。onValueChange 參數是用來當做 onValueChange 回呼,傳遞至 TextField 可組合函式。

  1. EditNumberField() 函式中更新 TextField() 可組合函式,以便使用傳入的參數:
TextField(
   value = value,
   onValueChange = onValueChange,
   // Rest of the code
)
  1. 將所記住的狀態從 EditNumberField() 函式移至 TipTimeLayout() 函式,以便提升狀態:
@Composable
fun TipTimeLayout() {
   var amountInput by remember { mutableStateOf("") }

   val amount = amountInput.toDoubleOrNull() ?: 0.0
   val tip = calculateTip(amount)
  
   Column(
       //...
   ) {
       //...
   }
}
  1. 您已將狀態提升至 TipTimeLayout(),現在請將其傳遞至 EditNumberField()。在 TipTimeLayout() 函式中,將 EditNumberField() 函式呼叫更新為使用提升後的狀態:
EditNumberField(
   value = amountInput,
   onValueChange = { amountInput = it },
   modifier = Modifier
       .padding(bottom = 32.dp)
       .fillMaxWidth()
)

這會將 EditNumberField 變成無狀態。您已將 UI 狀態提升至祖系 TipTimeLayout()TipTimeLayout() 現在是狀態 (amountInput) 擁有者。

位置格式設定

位置格式設定可用來在字串中顯示動態內容。舉例來說,假設您想讓「Tip amount」文字方塊顯示 xx.xx 值,且該值可以是由函式計算並設定格式的任何金額。如要在 strings.xml 檔案中完成這項作業,您需要使用預留位置引數來定義字串資源,如以下程式碼片段所示:

// No need to copy.

// In the res/values/strings.xml file
<string name="tip_amount">Tip Amount: %s</string>

在 Compose 程式碼中,您可以使用多個任何類型的預留位置引數。string 預留位置是 %s

請注意 TipTimeLayout() 中的文字可組合函式,您會以格式化的小費做為引數,傳遞至 stringResource() 函式。

// No need to copy
Text(
   text = stringResource(R.string.tip_amount, "$0.00"),
   style = MaterialTheme.typography.displaySmall
)
  1. 在函式 TipTimeLayout() 中,請使用 tip 屬性顯示小費金額。請更新 Text 可組合函式的 text 參數,將 tip 變數用做參數。
Text(
     text = stringResource(R.string.tip_amount, tip),
     // ...

已完成的 TipTimeLayout()EditNumberField() 函式應如下列程式碼片段所示:

@Composable
fun TipTimeLayout() {
   var amountInput by remember { mutableStateOf("") }
   val amount = amountInput.toDoubleOrNull() ?: 0.0
   val tip = calculateTip(amount)

   Column(
       modifier = Modifier
            .statusBarsPadding()
            .padding(horizontal = 40.dp)
            .verticalScroll(rememberScrollState())
            .safeDrawingPadding(),
       horizontalAlignment = Alignment.CenterHorizontally,
       verticalArrangement = Arrangement.Center
   ) {
       Text(
           text = stringResource(R.string.calculate_tip),
           modifier = Modifier
               .padding(bottom = 16.dp, top = 40.dp)
               .align(alignment = Alignment.Start)
       )
       EditNumberField(
           value = amountInput,
           onValueChange = { amountInput = it },
           modifier = Modifier
               .padding(bottom = 32.dp)
               .fillMaxWidth()
       )
       Text(
           text = stringResource(R.string.tip_amount, tip),
           style = MaterialTheme.typography.displaySmall
       )
       Spacer(modifier = Modifier.height(150.dp))
   }
}

@Composable
fun EditNumberField(
   value: String,
   onValueChange: (String) -> Unit,
   modifier: Modifier = Modifier
) {
   TextField(
       value = value,
       onValueChange = onValueChange,
       singleLine = true,
       label = { Text(stringResource(R.string.bill_amount)) },
       keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
       modifier = modifier
   )
}

總結來說,您已將 amountInput 狀態從 EditNumberField() 提升至 TipTimeLayout() 可組合函式。為了讓文字方塊照常運作,您必須將兩個引數傳入 EditNumberField() 可組合函式:amountInput 值,以及根據使用者輸入內容更新 amountInput 值的 lambda 回呼。這些變更可讓您透過 TipTimeLayout() 中的 amountInput 屬性計算小費,並向使用者顯示小費計算結果。

  1. 在模擬器或裝置上執行應用程式,然後在「Bill Amount」文字方塊中輸入值。應用程式隨即會顯示帳單金額 15% 的小費金額,如下圖所示:

de593783dc813e24.png

11. 取得解決方案程式碼

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

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-tip-calculator.git
$ cd basic-android-kotlin-compose-training-tip-calculator
$ git checkout state

另外,您也能以 ZIP 檔案格式下載存放區,再將檔案解壓縮,然後在 Android Studio 中開啟。

如要查看解決方案程式碼,請前往 GitHub 檢視

12. 結語

恭喜!您已成功完成這個程式碼研究室,也已瞭解如何使用 Compose 應用程式中的狀態!

摘要

  • 應用程式中的狀態指任何可能隨時間變化的值。
  • 「組成」是指 Compose 在執行可組合函式時建構的 UI 描述。Compose 應用程式會呼叫可組合函式,將資料轉換為 UI。
  • 初始組成是指 Compose 會在第一次執行可組合函式時建立 UI。
  • 重新組成是指再次執行相同可組合函式的程序,可在資料變更時更新樹狀結構。
  • 狀態提升是將狀態移往呼叫端的模式,可讓元件變成無狀態。

瞭解詳情