Compose 中的状态简介

1. 开始之前

本 Codelab 介绍了状态,以及 Jetpack Compose 如何使用和操纵状态。

从核心来讲,应用中的状态是任何随时间变化的值。这个定义非常宽泛,包括了从数据库到应用中的变量的一切。你将在后续单元中学习更多关于数据库的知识,但现在你只需要知道数据库是结构化信息的有序集合,例如计算机上的文件。

所有 Android 应用都会向用户显示状态。Android 应用中状态的一些示例包括

  • 网络连接无法建立时显示的消息。
  • 表单,例如注册表单。状态可以被填写和提交。
  • 可点击的控件,例如按钮。状态可以是 未点击正在点击(显示动画)或 已点击(一次 onClick 操作)。

在本 Codelab 中,您将探索如何在 Compose 中使用和思考状态。为此,您将使用这些内置的 Compose UI 元素构建一个名为 Tip Time 的小费计算器应用:

  • 一个用于输入和编辑文本的 TextField 可组合项。
  • 一个用于显示文本的 Text 可组合项。
  • 一个用于在 UI 元素之间显示空白空间的 Spacer 可组合项。

在本 Codelab 结束时,您将构建一个交互式小费计算器,当您输入服务金额时,它会自动计算小费金额。此图片展示了最终应用的外观:

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(总金额)就会更新。在接下来的 Codelab 结束时,您将在 Android 中开发一个类似的小费计算器应用。

在这个学习路径中,您将构建一个简单的小费计算器 Android 应用。

开发者通常会以这种方式工作——先准备好并运行一个简单版本的应用(即使它看起来不太好),然后再添加更多功能并使其在视觉上更具吸引力。

在本 Codelab 结束时,您的小费计算器应用将如下图所示。当用户输入账单金额时,您的应用将显示建议的小费金额。小费百分比目前硬编码为 15%。在下一个 Codelab 中,您将继续完善您的应用并添加更多功能,例如设置自定义小费百分比。

3. 获取起始代码

起始代码是预先编写好的代码,可以用作新项目的起点。它还可以帮助您专注于本 Codelab 中教授的新概念。

从这里下载起始代码开始:

或者,您可以克隆代码的 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

此文件主要包含模板生成的代码和以下函数。

  • The TipTimeLayout() function contains a Column element with two text composables that you see in the screenshots. This also has spacer composable to add space for aesthetic reasons. -> TipTimeLayout() 函数包含一个 Column 元素,其中包含您在截图中看到的两个文本可组合项。它还包含 spacer 可组合项,用于出于美观原因添加空间。
  • The calculateTip() function that accepts the bill amount and calculates a 15% tip amount. The tipPercent parameter is set to a 15.0 default argument value. This sets the default tip value to 15% for now. In the next codelab, you get the tip amount from the user. -> calculateTip() 函数接受账单金额并计算 15% 的小费金额。tipPercent 参数设置为 15.0 的默认参数值。这暂时将默认小费值设置为 15%。在下一个 Codelab 中,您将从用户那里获取小费金额。
@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)
}

In the onCreate() function's Surface() block, the TipTimeLayout() function is being called. This displays the app's layout in the device or the emulator. -> 在 onCreate() 函数的 Surface() 块中,调用了 TipTimeLayout() 函数。这会在设备或模拟器中显示应用的布局。

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

The TipTimeLayoutPreview() function's TipTimeTheme block, the TipTimeLayout() function is being called. This displays the app's layout in the Design and in the Split pane. -> 在 TipTimeLayoutPreview() 函数的 TipTimeTheme 块中,调用了 TipTimeLayout() 函数。这会在 Design(设计)和 Split(拆分)窗格中显示应用的布局。

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

ae11354e61d2a2b9.png

获取用户输入

在本节中,您将添加一个 UI 元素,让用户可以在应用中输入账单金额。您可以在此图片中看到它的外观:

58671affa01fb9e1.png

您的应用使用自定义样式和主题。

样式和主题是指定单个 UI 元素外观的属性集合。样式可以指定字体颜色、字体大小、背景颜色等许多属性,这些属性可以应用于整个应用。后续的 Codelab 将介绍如何在您的应用中实现这些。目前,为了让您的应用更美观,这些工作已经为您完成。

为了更好地理解,以下是应用解决方案版本在有无自定义主题下的并排比较:

无自定义主题。

有自定义主题。

The TextField composable function lets the user enter text in an app. For example, notice the text box on the login screen of the Gmail app in this image -> TextField 可组合函数允许用户在应用中输入文本。例如,请注意此图片中 Gmail 应用登录屏幕上的文本框:

Phone screen with gmail app with a text field for email

TextField 可组合项添加到应用中

  1. In the MainActivity.kt file, add an EditNumberField() composable function, that takes a Modifier parameter. -> 在 MainActivity.kt 文件中,添加一个 EditNumberField() 可组合函数,它接受一个 Modifier 参数。
  2. In the body of the EditNumberField() function below TipTimeLayout(), add a TextField that accepts a value named parameter set to an empty string and an onValueChange named parameter set to an empty lambda expression -> 在 TipTimeLayout() 下方的 EditNumberField() 函数主体中,添加一个 TextField,它接受一个名为 value 的参数,并将其设置为空字符串,以及一个名为 onValueChange 的参数,并将其设置为空 lambda 表达式:
@Composable
fun EditNumberField(modifier: Modifier = Modifier) {
   TextField(
      value = "",
      onValueChange = {},
      modifier = modifier
   )
}
  1. 注意您传递的参数:
  • The value parameter is a text box that displays the string value you pass here. -> value 参数是一个文本框,用于显示您在此处传递的字符串值。
  • The onValueChange parameter is the lambda callback that's triggered when the user enters text in the text box. -> onValueChange 参数是当用户在文本框中输入文本时触发的 lambda 回调。
  1. 导入此函数:
import androidx.compose.material3.TextField
  1. In the TipTimeLayout() composable, on the line after the first text composable function, call the EditNumberField() function, passing the following modifier. -> 在 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. At the beginning of the EditNumberField() function, use the val keyword to add an amountInput variable set it to "0" value -> 在 EditNumberField() 函数的开头,使用 val 关键字添加一个 amountInput 变量,并将其设置为 "0" 值:
val amountInput = "0"

这是应用的账单金额状态。

  1. 将名为 value 的参数设置为 amountInput 值:
TextField(
   value = amountInput,
   onValueChange = {},
)
  1. 查看预览。文本框显示设置为状态变量的值,如您在此图片中看到:

e8e24821adfd9d8c.png

  1. 在模拟器中运行应用,尝试输入不同的值。硬编码的状态保持不变,因为 TextField 可组合项不会自行更新。当其 value 参数(设置为 amountInput 属性)改变时,它才会更新。

The amountInput variable represents the state of the text box. Having a hardcoded state isn't useful because it can't be modified and it doesn't reflect user input. You need to update the state of the app when the user updates the bill amount. -> amountInput 变量表示文本框的状态。硬编码的状态没有用,因为它无法修改,也无法反映用户输入。当用户更新账单金额时,您需要更新应用的状态。

5. 组合(Composition)

应用中的可组合项描述了一个 UI,它显示一个列,其中包含一些文本、一个间隔符和一个文本框。文本显示 Calculate Tip 文本,文本框显示 0 值或任何默认值。

Compose is a declarative UI framework, meaning that you declare how the UI should look in your code. If you wanted your text box to show a 100 value initially, you'd set the initial value in the code for the composables to a 100 value. -> Compose 是一个声明式 UI 框架,这意味着您在代码中*声明* UI 应该是什么样子。如果您希望您的文本框最初显示 100 值,您将在可组合项的代码中将初始值设置为 100 值。

What happens if you want your UI to change while the app is running or as the user interacts with the app? For example, what if you wanted to update the amountInput variable with the value entered by the user and display it in the text box? That's when you rely on a process called recomposition to update the Composition of the app. -> 如果您想在应用运行时或用户与应用交互时更改 UI 怎么办?例如,如果您想用用户输入的值更新 amountInput 变量并在文本框中显示它,该怎么办?这时就需要依赖一个称为重组(recomposition)的过程来更新应用的组合(Composition)。

The Composition is a description of the UI built by Compose when it executes composables. Compose apps call composable functions to transform data into UI. If a state change happens, Compose re-executes the affected composable functions with the new state, which creates an updated UI—this is called recomposition. Compose schedules a recomposition for you. -> *组合(Composition)*是 Compose 执行可组合项时构建的 UI 描述。Compose 应用调用可组合函数将数据转换为 UI。如果发生状态变化,Compose 会使用新状态重新执行受影响的可组合函数,从而创建更新的 UI——这称为*重组(recomposition)*。Compose 会为您安排*重组*。

When Compose runs your composables for the first time during initial composition, it keeps track of the composables that you call to describe your UI in a Composition. Recomposition is when Compose re-executes the composables that may have changed in response to data changes and then updates the Composition to reflect any changes. -> 在*初始组合*期间,当 Compose 首次运行您的可组合项时,它会在组合(Composition)中跟踪您调用以描述 UI 的可组合项。*重组(Recomposition)*是指 Compose 响应数据变化重新执行可能已更改的可组合项,然后更新组合(Composition)以反映任何更改。

The Composition can only be produced by an initial composition and updated by recomposition. The only way to modify the Composition is through recomposition. To do this, Compose needs to know what state to track so that it can schedule the recomposition when it receives an update. In your case, it's the amountInput variable, so whenever its value changes, Compose schedules a recomposition. -> 组合(Composition)只能通过*初始组合*生成,并由*重组*更新。修改组合的唯一方法是通过*重组*。为此,*Compose 需要知道要跟踪哪些状态*,以便在收到更新时安排重组。在您的案例中,它是 amountInput 变量,因此无论其值何时更改,Compose 都会安排一次重组。

You use the State and MutableState types in Compose to make state in your app observable, or tracked, by Compose. The State type is immutable, so you can only read the value in it, while the MutableState type is mutable. You can use the mutableStateOf() function to create an observable MutableState. It receives an initial value as a parameter that is wrapped in a State object, which then makes its value observable. -> 您在 Compose 中使用 StateMutableState 类型,使应用中的状态可由 Compose 观察或跟踪。State 类型是不可变的,因此您只能读取其中的值,而 MutableState 类型是可变的。您可以使用 mutableStateOf() 函数创建一个可观察的 MutableState。它接收一个初始值作为参数,该值被包装在 State 对象中,从而使其 value 可观察。

The value returned by the mutableStateOf() function -> mutableStateOf() 函数返回的值:

  • 持有状态,即账单金额。
  • 是可变的,因此值可以更改。
  • 是可观察的,因此 Compose 会观察对值的任何更改并触发重组以更新 UI。

添加服务成本状态

  1. In the EditNumberField() function, change the val keyword before the amountInput state variable to the var keyword -> 在 EditNumberField() 函数中,将 amountInput 状态变量之前的 val 关键字更改为 var 关键字:
var amountInput = "0"

这使得 amountInput 可变。

  1. Use the MutableState<String> type instead of the hardcoded String variable so that Compose knows to track the amountInput state and then pass in a "0" string, which is the initial default value for the amountInput state variable -> 使用 MutableState<String> 类型代替硬编码的 String 变量,以便 Compose 知道跟踪 amountInput 状态,然后传入 "0" 字符串,这是 amountInput 状态变量的初始默认值:
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf

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

The amountInput initialization can also be written like this with type inference -> amountInput 初始化也可以使用类型推断这样编写:

var amountInput = mutableStateOf("0")

The mutableStateOf() function receives an initial "0" value as an argument, which then makes amountInput observable. This results in this compilation warning in Android Studio, but you fix it soon -> mutableStateOf() 函数接收一个初始的 "0" 值作为参数,这使得 amountInput 可观察。这会在 Android Studio 中导致此编译警告,但您很快就会修复它:

Creating a state object during composition without using remember.
  1. In the TextField composable function, use the amountInput.value property -> 在 TextField 可组合函数中,使用 amountInput.value 属性:
TextField(
   value = amountInput.value,
   onValueChange = {},
   modifier = modifier
)

Compose keeps track of each composable that reads state value properties and triggers a recomposition when its value changes. -> Compose 会跟踪读取状态 value 属性的每个可组合项,并在其 value 更改时触发重组。

The onValueChange callback is triggered when the text box's input changes. In the lambda expression, the it variable contains the new value. -> 当文本框的输入发生变化时,会触发 onValueChange 回调。在 lambda 表达式中,it 变量包含新值。

  1. In the onValueChange named parameter's lambda expression, set the amountInput.value property to the it variable -> 在 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
   )
}

You are updating the state of the TextField (that is the amountInput variable), when the TextField notifies you that there is a change in the text through onValueChange callback function. -> 当 TextField 通过 onValueChange 回调函数通知您文本发生更改时,您正在更新 TextField 的状态(即 amountInput 变量)。

  1. Run the app and enter text in the text box. The text box still shows a 0 value as you can see in this image -> 运行应用并在文本框中输入文本。文本框仍然显示 0 值,如您在此图片中看到:

3a2c62f8ec55e339.gif

When the user enters text in the text box, the onValueChange callback is called and the amountInput variable is updated with the new value. The amountInput state is tracked by Compose, so the moment that its value changes, recomposition is scheduled and the EditNumberField() composable function is executed again. In that composable function, the amountInput variable is reset to its initial 0 value. Thus, the text box shows a 0 value. -> 当用户在文本框中输入文本时,会调用 onValueChange 回调,并使用新值更新 amountInput 变量。amountInput 状态由 Compose 跟踪,因此其值发生变化的瞬间,就会安排重组,并再次执行 EditNumberField() 可组合函数。在该可组合函数中,amountInput 变量会重置为其初始的 0 值。因此,文本框显示 0 值。

使用您添加的代码,状态更改会导致安排重组。

However, you need a way to preserve the value of the amountInput variable across recompositions so that it's not reset to a 0 value each time that the EditNumberField() function recomposes. You resolve this issue in the next section. -> 然而,您需要一种方法来跨重组保留 amountInput 变量的值,以便在 EditNumberField() 函数每次重组时,它不会重置为 0 值。您将在下一节中解决这个问题。

6. 使用 remember 函数保存状态

Composable methods can be called many times because of recomposition. The composable resets its state during recomposition if it's not saved. -> 可组合方法由于重组可能被多次调用。如果未保存,可组合项会在重组期间重置其状态。

Composable functions can store an object across recompositions with the remember. A value computed by the remember function is stored in the Composition during initial composition and the stored value is returned during recomposition. Usually remember and mutableStateOf functions are used together in composable functions to have the state and its updates be reflected properly in the UI. -> 可组合函数可以使用 remember 在重组期间存储对象。remember 函数计算的值在初始组合期间存储在组合(Composition)中,并在重组期间返回存储的值。remembermutableStateOf 函数通常在可组合函数中一起使用,以使状态及其更新正确反映在 UI 中。

EditNumberField() 函数中使用 remember 函数

  1. In the EditNumberField() function, initialize the amountInput variable with the by remember Kotlin property delegate, by surrounding the call to mutableStateOf() function with remember. -> 在 EditNumberField() 函数中,使用 by remember Kotlin 属性委托初始化 amountInput 变量,通过用 remember 围绕对 mutableStateOf() 函数的调用。
  2. In the mutableStateOf() function, pass in an empty string instead of a static "0" string -> 在 mutableStateOf() 函数中,传入一个空字符串而不是静态的 "0" 字符串:
var amountInput by remember { mutableStateOf("") }

Now the empty string is the initial default value for the amountInput variable. by is a Kotlin property delegation. The default getter and setter functions for the amountInput property are delegated to the remember class's getter and setter functions, respectively. -> 现在空字符串是 amountInput 变量的初始默认值。by 是一个 Kotlin 属性委托amountInput 属性的默认 getter 和 setter 函数分别委托给 remember 类的 getter 和 setter 函数。

  1. 导入这些函数:
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue

Adding the delegate's getter and setter imports lets you read and set amountInput without referring to the MutableState's value property. -> 添加委托的 getter 和 setter 导入允许您读取和设置 amountInput,而无需引用 MutableStatevalue 属性。

更新后的 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. EditNumberField() 函数中紧邻 onValueChange 命名参数的那一行,设置一个行断点。
  2. In the navigation menu, click Debug ‘app'. The app launches on the emulator or device. Your app's execution pauses for the first time when the TextField element is created. -> 在导航菜单中,点击 Debug ‘app'(调试“应用”)。应用将在模拟器或设备上启动。当创建 TextField 元素时,您的应用执行将首次暂停。

154e060231439307.png

  1. In the Debug pane, click 2a29a3bad712bec.png Resume Program. The text box is created. -> 在 Debug(调试)窗格中,点击 2a29a3bad712bec.png Resume Program(恢复程序)。文本框已创建。
  2. On the emulator or device, enter a letter in the text box. Your app's execution pauses again when it reaches the breakpoint that you set. -> 在模拟器或设备上,在文本框中输入一个字母。当应用执行到达您设置的断点时,会再次暂停。

When you enter the text, the onValueChange callback is called. Inside the lambda it has the new value you typed in the keypad. -> 当您输入文本时,会调用 onValueChange 回调。在 lambda 内部,it 包含您在键盘中输入的新值。

Once the value of "it" is assigned to amountInput then Compose triggers the recomposition with the new data as the observable value has changed. -> 一旦将“it”的值分配给 amountInput,Compose 就会使用新数据触发重组,因为可观察值已更改。

1d5e08d32052d02e.png

  1. In the Debug pane, click 2a29a3bad712bec.png Resume Program. The text entered in the emulator or on the device displays next to the line with the breakpoint as seen in this image -> 在 Debug(调试)窗格中,点击 2a29a3bad712bec.png Resume Program(恢复程序)。在模拟器或设备中输入的文本将显示在断点所在行旁边,如在此图片中看到:

1f5db6ab5ca5b477.png

这是文本字段的状态。

  1. Click 2a29a3bad712bec.png Resume Program. The value entered is displayed on the emulator or device. -> 点击 2a29a3bad712bec.png Resume Program(恢复程序)。输入的值将显示在模拟器或设备上。

8. 修改外观

在上一节中,您成功地让文本字段工作了。在本节中,您将改进 UI。

为文本框添加标签

Every text box should have a label that lets users know what information they can enter. In the first part of the following example image, the label text is in the middle of a text field and aligned with the input line. In the second part of the following example image, the label is moved higher in the text box when the user clicks in the text box to enter text. To learn more about text-field anatomy, see Anatomy. -> 每个文本框都应该有一个标签,让用户知道他们可以输入什么信息。在下面示例图片的第一部分中,标签文本位于文本字段中间,与输入行对齐。在示例图片的第二部分中,当用户点击文本框输入文本时,标签会向上移动到文本框的上方。要了解更多关于文本字段结构的信息,请参阅 Anatomy(结构)。

a2afd6c7fc547b06.png

修改 EditNumberField() 函数,为文本字段添加标签

  1. In the EditNumberField() function's TextField() composable function , add a label named parameter set to an empty lambda expression -> 在 EditNumberField() 函数的 TextField() 可组合函数中,添加一个名为 label 的参数,并将其设置为空 lambda 表达式:
TextField(
//...
   label = { }
)
  1. In the lambda expression, call the Text() function that accepts a stringResource(R.string.bill_amount) -> 在 lambda 表达式中,调用接受 *stringResource*(R.string.*bill_amount*)Text() 函数:
label = { Text(stringResource(R.string.bill_amount)) },
  1. In the TextField() composable function, add singleLine named parameter set to a true value -> 在 TextField() 可组合函数中,添加一个名为 singleLine 的参数,并将其设置为 true 值:
TextField(
  // ...
   singleLine = true,
)

这将把文本框从多行压缩为单行可水平滚动的行。

  1. 添加 keyboardOptions 参数,并将其设置为 KeyboardOptions()
import androidx.compose.foundation.text.KeyboardOptions

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

Android provides an option to configure the keyboard displayed on the screen to enter digits, email addresses, URLs, and passwords, to name a few. To learn more about other keyboard types, see KeyboardType. -> Android 提供了一个选项来配置屏幕上显示的键盘,以输入数字、电子邮件地址、URL 和密码等。要了解更多关于其他键盘类型的信息,请参阅 KeyboardType

  1. Set the keyboard type to number keyboard to input digits. Pass the KeyboardOptions function a keyboardType named parameter set to a KeyboardType.Number -> 将键盘类型设置为数字键盘以输入数字。向 KeyboardOptions 函数传递一个名为 keyboardType 的参数,并将其设置为 KeyboardType.Number
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. 显示小费金额

在本节中,您将实现应用的主要功能,即计算和显示小费金额的能力。

In the MainActivity.kt file, a private calculateTip() function is given to you as part of the starter code. You will use this function to calculate the tip amount -> 在 MainActivity.kt 文件中,起始代码已提供了一个 private calculateTip() 函数。您将使用此函数计算小费金额:

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

In the above method you are using NumberFormat to display the format of the tip as currency. -> 在上述方法中,您使用了 NumberFormat 来以货币格式显示小费。

现在您的应用可以计算小费了,但您仍然需要使用该类来格式化和显示它。

使用 calculateTip() 函数

The text entered by the user in the text field composable is returned to the onValueChange callback function as a String even though the user entered a number. To fix this, you need to convert the amountInput value, which contains the amount entered by the user. -> 用户在文本字段可组合项中输入的文本作为 String 返回给 onValueChange 回调函数,即使用户输入的是数字。要解决此问题,您需要转换包含用户输入的金额的 amountInput 值。

  1. In EditNumberField() composable function, create a new variable called amount after the amountInput definition. Call the toDoubleOrNull function on the amountInput variable, to convert the String to a Double -> 在 EditNumberField() 可组合函数中,在定义 amountInput 之后创建一个名为 amount 的新变量。对 amountInput 变量调用 toDoubleOrNull 函数,将 String 转换为 Double
val amount = amountInput.toDoubleOrNull()

The toDoubleOrNull() function is a predefined Kotlin function that parses a string as a Double number and returns the result or null if the string isn't a valid representation of a number. -> toDoubleOrNull() 函数是一个预定义的 Kotlin 函数,它将字符串解析为 Double 数字并返回结果,如果字符串不是有效的数字表示,则返回 null

  1. At the end of the statement, add an ?: Elvis operator that returns a 0.0 value when amountInput is null -> 在语句末尾,添加一个 ?: Elvis 运算符,当 amountInput 为 null 时,它返回 0.0 值:
val amount = amountInput.toDoubleOrNull() ?: 0.0
  1. After the amount variable, create another val variable called tip. Initialize it with the calculateTip(), passing the amount parameter. -> 在 amount 变量之后,创建另一个名为 tipval 变量。使用 calculateTip() 初始化它,并传入 amount 参数。
val tip = calculateTip(amount)

The EditNumberField() function should look like this code snippet -> 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)
   )
}

显示计算后的小费金额

You have written the function to calculate the tip amount, the next step is to display the calculated tip amount -> 您已经编写了计算小费金额的函数,下一步是显示计算后的小费金额:

  1. In the TipTimeLayout() function at the end of the Column() block, notice the text composable that displays $0.00. You will update this value to the calculated tip amount. -> 在 TipTimeLayout() 函数的 Column() 块末尾,请注意显示 $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
        )
        // ...
    }
}

You need to access the amountInput variable in the TipTimeLayout() function to calculate and display the tip amount, but the amountInput variable is the state of the text field defined in the EditNumberField() composable function, so you can't call it from the TipTimeLayout() function yet. This image illustrates the structure of the code -> 您需要在 TipTimeLayout() 函数中访问 amountInput 变量来计算并显示小费金额,但 amountInput 变量是在 EditNumberField() 可组合函数中定义的文本字段的状态,因此您还不能从 TipTimeLayout() 函数中调用它。此图片展示了代码结构:

50bf0b9d18ede6be.png

This structure won't let you display the tip amount in the new Text composable because the Text composable needs to access the amount variable calculated from the amountInput variable. You need to expose the amount variable to the TipTimeLayout() function. This image illustrates the desired code structure, which makes the EditNumberField() composable stateless -> 此结构不允许您在新的 Text 可组合项中显示小费金额,因为 Text 可组合项需要访问从 amountInput 变量计算出的 amount 变量。您需要将 amount 变量暴露给 TipTimeLayout() 函数。此图片展示了期望的代码结构,这使得 EditNumberField() 可组合项无状态:

ab4ec72388149f7c.png

这种模式称为*状态提升*(state hoisting)。在下一节中,您将*提升*(hoist),或称之为*提起*(lift),从一个可组合项中提取状态,使其成为无状态可组合项。

10. 状态提升

在本节中,您将学习如何决定在哪里定义状态,以便可以重用和共享可组合项。

In a composable function, you can define variables that hold state to display in the UI. For example, you defined the amountInput variable as state in the EditNumberField() composable. -> 在可组合函数中,您可以定义持有状态的变量以在 UI 中显示。例如,您在 EditNumberField() 可组合项中将 amountInput 变量定义为状态。

When your app becomes more complex and other composables need access to the state within the EditNumberField() composable, you need to consider hoisting, or extracting, the state out of the EditNumberField() composable function. -> 当您的应用变得更加复杂,并且其他可组合项需要访问 EditNumberField() 可组合项中的状态时,您需要考虑将状态从 EditNumberField() 可组合函数中提升或提取出来。

理解有状态(stateful)与无状态(stateless)可组合项

您应该在需要时提升状态:

  • 与多个可组合函数共享状态。
  • 创建一个可在应用中重用的无状态可组合项。

When you extract state from a composable function, the resulting composable function is called stateless. That is, composable functions can be made stateless by extracting state from them. -> 当您从可组合函数中提取状态时,由此产生的可组合函数称为无状态。也就是说,可以通过从可组合函数中提取状态来使其成为无状态。

A stateless composable is a composable that doesn't have a state, meaning it doesn't hold, define, or modify a new state. On the other hand, a stateful composable is a composable that owns a piece of state that can change over time. -> *无状态*可组合项是指没有状态的可组合项,这意味着它不持有、定义或修改新状态。另一方面,*有状态*可组合项是指拥有随时间变化的状态片段的可组合项。

状态提升是将状态移动到其调用者以使组件无状态的一种模式。

当应用于可组合项时,这通常意味着向可组合项引入两个参数:

  • 一个 value: T 参数,它是要显示当前值。
  • 一个 onValueChange: (T) -> Unit – 回调 lambda,当值改变时触发,以便可以在其他地方更新状态,例如当用户在文本框中输入一些文本时。

EditNumberField() 函数中提升状态

  1. Update the EditNumberField() function definition, to hoist the state by adding the value and onValueChange parameters -> 更新 EditNumberField() 函数定义,通过添加 valueonValueChange 参数来提升状态:
@Composable
fun EditNumberField(
   value: String,
   onValueChange: (String) -> Unit,
   modifier: Modifier = Modifier
) {
//...

The value parameter is of String type, and the onValueChange parameter is of (String) -> Unit type, so it's a function that takes a String value as input and has no return value. The onValueChange parameter is used as the onValueChange callback passed into the TextField composable. -> value 参数是 String 类型,onValueChange 参数是 (String) -> Unit 类型,因此它是一个函数,接受 String 值作为输入且没有返回值。onValueChange 参数用作传递给 TextField 可组合项的 onValueChange 回调。

  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)的拥有者。

位置格式化

Positional formatting is used to display dynamic content in strings. For example, imagine that you want the Tip amount text box to display an xx.xx value that could be any amount calculated and formatted in your function. To accomplish this in the strings.xml file, you need to define the string resource with a placeholder argument, like this code snippet -> 位置格式化用于在字符串中显示动态内容。例如,想象一下您希望 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>

In the compose code, you can have multiple, and any type of, placeholder arguments. A string placeholder is %s. -> 在 compose 代码中,您可以有多个任意类型的占位符参数。一个 string 占位符是 %s

Notice the text composable in TipTimeLayout(), you pass in the formatted tip as an argument to the stringResource() function. -> 请注意 TipTimeLayout() 中的文本可组合项,您将格式化后的小费作为参数传递给 stringResource() 函数。

// No need to copy
Text(
   text = stringResource(R.string.tip_amount, "$0.00"),
   style = MaterialTheme.typography.displaySmall
)
  1. In the function, TipTimeLayout(), use the tip property to display the tip amount. Update the Text composable's text parameter to use the tip variable as a parameter. -> 在 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. 在模拟器或设备上运行应用,然后在账单金额文本框中输入一个值。将显示账单金额 15% 的小费金额,如您在此图片中看到:

de593783dc813e24.png

11. 获取解决方案代码

要下载已完成的 Codelab 代码,您可以使用这些 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. 总结

恭喜!您已完成本 Codelab 并学会了如何在 Compose 应用中使用状态!

总结

  • 应用中的状态是任何随时间变化的值。
  • 组合(Composition)是 Compose 执行可组合项时构建的 UI 描述。Compose 应用调用可组合函数将数据转换为 UI。
  • 初始组合是 Compose 首次执行可组合函数时创建 UI 的过程。
  • 重组是在数据变化时再次运行相同的可组合项以更新树的过程。
  • 状态提升是将状态移动到其调用者以使组件无状态的一种模式。

了解更多