1. 准备工作
在本 Codelab 中,我们将使用 Codelab 课程“Compose 中的状态简介”中的解决方案代码构建一款交互式小费计算器;在您输入账单金额和小费百分比后,该计算器可以自动计算小费金额并进行取整处理。最终应用将如下图所示:

前提条件
- 已学习“在 Jetpack Compose 中使用状态”Codelab
 - 能够向应用添加 
Text和TextField可组合项。 - 了解 
remember函数、状态、状态提升,以及有状态和无状态可组合函数之间的差异 
学习内容
- 如何向虚拟键盘添加操作按钮。
 - 如何设置键盘操作。
 - 什么是 
Switch可组合项以及如何使用它。 - 什么是布局检查器。
 
构建内容
- 您将构建一款 Tip Time 应用,它会根据用户输入的服务费用和小费百分比计算小费金额。
 
所需条件
- Android Studio
 - “在 Jetpack Compose 中使用状态”Codelab 中的解决方案代码
 
2. 起始应用概览
本 Codelab 将从上一个 Codelab 中的 Tip Time 应用入手,该应用提供了根据固定小费百分比计算小费所需的界面。用户可以通过 Cost of Service 文本框输入服务费用。该应用将计算小费金额,并在 Text 可组合项中显示小费金额。
  | 
  | 
获取起始代码
首先,请下载起始代码:
或者,您也可以克隆代码的 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 state
您可以在 Tip Calculator GitHub 代码库中浏览该代码。
运行 Tip Time 应用
- 在 Android Studio 中打开 Tip Time 项目,并在模拟器或设备上运行应用。
 - 输入服务费用。应用会自动计算并显示小费金额。
 

在当前的实现中,小费百分比已硬编码为 15%。在本 Codelab 中,您将使用文本字段扩展该功能,让应用能够根据自定义小费百分比计算小费金额并进行取整处理。
添加必要的字符串资源
- 在 Project 标签页中,依次点击 res > values > strings.xml。
 - 在 
strings.xml文件的<resources>标记之间,添加以下字符串资源: 
<string name="how_was_the_service">Tip (%)</string>
<string name="round_up_tip">Round up tip?</string>
strings.xml 文件应如以下代码段所示(其中包含上一个 Codelab 中的字符串):
strings.xml
<resources>
   <string name="app_name">TipTime</string>
   <string name="calculate_tip">Calculate Tip</string>
   <string name="cost_of_service">Cost of Service</string>
   <string name="how_was_the_service">Tip (%)</string>
   <string name="round_up_tip">Round up tip?</string>
   <string name="tip_amount">Tip Amount: %s</string>
</resources>
- 将 
Cost Of Service字符串更改为Bill Amount字符串。在某些国家/地区,“service”就是“tip”的意思,因此这样更改可以避免混淆。 - 在 
Cost of Service字符串中,右键点击该属性的namecost_of_service,然后依次选择 Refactor > Rename。系统会打开 Rename 对话框。 

- 在 Rename 对话框中,将 
cost_of _service替换为bill_amount,然后点击 Refactor。这会更新项目中出现的所有cost_of_service字符串资源,因此您无需手动更改 Compose 代码。 

- 在 
strings.xml文件中,将字符串值从Cost of Service更改为Bill Amount: 
<string name="bill_amount">Bill Amount</string>
- 找到 
MainActivity.kt文件,然后运行应用。文本框中的标签已更新,如下图所示: 

3. 添加小费百分比文本字段
用户可能希望根据所提供的服务质量和其他各种原因提高或降低小费。为适应此需求,应用应允许用户计算自定义小费。在本部分中,我们将添加一个文本字段,供用户输入自定义小费百分比,如下图所示:

您的应用中已包含 Bill Amount 文本字段可组合项,它属于无状态 EditNumberField() 可组合函数。在上一个 Codelab 中,您已将 amountInput 状态从 EditNumberField() 可组合项提升到 TipTimeScreen() 函数,从而让 EditNumberField() 可组合项变为无状态。
如需添加文本字段,您可以重复使用同一 EditNumberField() 可组合项,但要提供不同的标签。如需进行此项更改,您需要将标签作为形参传入,而不是在 EditNumberField() 可组合函数内对其进行硬编码。
使 EditNumberField() 可组合函数可重复使用:
- 在 
MainActivity.kt文件中,将Int类型的label字符串资源添加到EditNumberField()可组合函数的形参中: 
@Composable
fun EditNumberField(
   label: Int,
   value: String,
   onValueChange: (String) -> Unit
) 
- 将 
Modifier类型的modifier实参添加到EditNumberField()可组合函数中: 
@Composable
fun EditNumberField(
   label: Int,
   value: String,
   onValueChange: (String) -> Unit,
   modifier: Modifier = Modifier
) 
- 在函数主体中,将硬编码的字符串资源 ID 替换为 
label形参: 
@Composable
fun EditNumberField(
   //...
) {
   TextField(
       //...
       label = { Text(stringResource(label)) },
       //...
   )
}
- 使用 
@StringRes注解为该函数形参添加注解,指明label形参应为字符串资源引用: 
@Composable
fun EditNumberField(
   @StringRes label: Int,
   value: String,
   onValueChange: (String) -> Unit,
   modifier: Modifier = Modifier
) 
- 导入以下代码:
 
import androidx.annotation.StringRes
- 在 
EditNumberField()函数的TextField可组合项中,将label形参传递到stringResource()函数。 
@Composable
fun EditNumberField(
   @StringRes label: Int,
   value: String,
   onValueChange: (String) -> Unit,
   modifier: Modifier = Modifier
) {
   TextField(
       //...
       label = { Text(stringResource(label)) },
       //...
   )
} 
- 在 
TipTimeScreen()函数的EditNumberField()函数调用中,将label形参设置为R.string.bill_amount字符串资源: 
EditNumberField(
   label = R.string.bill_amount,
   value = amountInput,
   onValueChange = { amountInput = it }
) 
- 在“Design”窗格中,点击 
 Build & Refresh。应用的界面应如下图所示: 

- 在 
TipTimeScreen()函数中的EditNumberField()函数调用后面,再添加一个文本字段,以供用户输入自定义小费百分比。使用以下形参调用EditNumberField()可组合函数: 
EditNumberField(
   label = R.string.how_was_the_service,
   value = "",
   onValueChange = { }
) 
这样即可再添加一个文本框,供用户输入自定义小费百分比。
- 在“Design”窗格中,点击 
 Build & Refresh。现在,应用预览会显示一个内容为 Tip (%) 的文本字段,如下图所示: 

- 在 
TipTimeScreen()函数的顶部,为已添加的文本字段的状态变量添加一个名为tipInput的var属性。使用mutableStateOf("")初始化该变量,并使用remember函数将调用括起来: 
var tipInput by remember { mutableStateOf("") }
- 在新的 
EditNumberField()函数调用中,将value具名形参设置为tipInput变量,然后更新onValueChangelambda 表达式中的tipInput变量: 
EditNumberField(
   label = R.string.how_was_the_service,
   value = tipInput, 
   onValueChange = { tipInput = it }
)
- 在 
TipTimeScreen()函数中tipInput变量的定义后面,定义一个名为tipPercent的val变量,以将tipInput变量转换为Double类型,并使用 elvis 运算符,在值为null时返回0.0: 
val tipPercent = tipInput.toDoubleOrNull() ?: 0.0
- 在 
TipTimeScreen()函数中,更新calculateTip()函数调用,传入tipPercent变量作为第二个形参: 
val tip = calculateTip(amount, tipPercent)
现在,TipTimeScreen() 函数的代码应如以下代码段所示:
@Composable
fun TipTimeScreen() {
   var amountInput by remember { mutableStateOf("") }
   var tipInput by remember { mutableStateOf("") }
   val tipPercent = tipInput.toDoubleOrNull() ?: 0.0
   val amount = amountInput.toDoubleOrNull() ?: 0.0
   val tip = calculateTip(amount, tipPercent)
   Column(
       modifier = Modifier.padding(32.dp),
       verticalArrangement = Arrangement.spacedBy(8.dp)
   ) {
       Text(
           text = stringResource(R.string.calculate_tip),
           fontSize = 24.sp,
           modifier = Modifier.align(Alignment.CenterHorizontally)
       )
       Spacer(Modifier.height(16.dp))
       EditNumberField(
           label = R.string.bill_amount,
           value = amountInput,
           onValueChange = { amountInput = it }
       )
       EditNumberField(
           label = R.string.how_was_the_service,
           value = tipInput,
           onValueChange = { tipInput = it }
       )
       Spacer(Modifier.height(24.dp))
       Text(
           text = stringResource(R.string.tip_amount, tip),
           modifier = Modifier.align(Alignment.CenterHorizontally),
           fontSize = 20.sp,
           fontWeight = FontWeight.Bold
       )
   }
}
- 在模拟器或设备上运行应用,然后输入账单金额和小费百分比。应用会正确计算小费金额吗?
 

4. 设置操作按钮
在上一个 Codelab 中,您学习了如何使用 KeyboardOptions 类设置键盘类型。在本部分中,您将学习如何使用相同的 KeyboardOptions 设置键盘操作按钮。键盘操作按钮是指键盘端的按钮。您可以参见下表,了解一些示例:
属性  | 键盘上的操作按钮  | 
  | 
  | 
  | 
  | 
  | 
  | 
在此任务中,您将为文本框设置两个不同的操作按钮:
- 为 Bill Amount 文本框设置 Next 操作按钮,用于指示用户已完成当前输入并想移到下一个文本框。
 - 为 Tip % 文本框设置 Done 操作按钮,用于指示用户已完成输入。
 
包含这些按钮的键盘示例如下面这些图片所示:
  | 
  | 
添加键盘选项:
- 在 
EditNumberField()函数的TextField()函数调用中,向KeyboardOptions构造函数传递一个值设置为ImeAction.Next的imeAction具名实参。您可以通过KeyboardOptions.Default.copy函数来使用其他默认选项,例如大写和自动更正。 
@Composable
fun EditNumberField(
   //...
) {
   TextField(
       //...
       keyboardOptions = KeyboardOptions.Default.copy(
           keyboardType = KeyboardType.Number,
           imeAction = ImeAction.Next
       )
   )
}
- 在模拟器或设备上运行应用。现在,键盘会显示 Next 操作按钮,如下图所示:
 
  | 
  | 
不过,您需要为文本字段设置两个不同的操作按钮。您很快将解决该问题。
- 检查 
EditNumberField()函数。TextField()函数中的keyboardOptions形参是硬编码的。若要为文本字段创建不同的操作按钮,您需要将KeyboardOptions对象作为实参传入(我们将在下一步中执行该操作)。 
// No need to copy, just examine the code.
fun EditNumberField(
   @StringRes label: Int,
   value: String,
   onValueChange: (String) -> Unit
) {
   TextField(
       //...
       keyboardOptions = KeyboardOptions.Default.copy(
          keyboardType = KeyboardType.Number,
          imeAction = ImeAction.Next
       )
   )
}
- 在 
EditNumberField()函数定义中,添加类型为KeyboardOptions的keyboardOptions形参。在函数主体中,将其分配给TextField()函数的keyboardOptions具名形参: 
@Composable
fun EditNumberField(
   @StringRes label: Int,
   keyboardOptions: KeyboardOptions,
   value: String,
   onValueChange: (String) -> Unit
){
   TextField(
       //...
       keyboardOptions = keyboardOptions
   )
}
- 在 
TipTimeScreen()函数中,更新第一个EditNumberField()函数调用,为 Bill Amount 文本字段传入keyboardOptions具名形参。 
EditNumberField(
   label = R.string.bill_amount,
   keyboardOptions = KeyboardOptions(
       keyboardType = KeyboardType.Number,
       imeAction = ImeAction.Next
   ),
   value = amountInput,
   onValueChange = { amountInput = it }
)
- 在第二个 
EditNumberField()函数调用中,将 Tip % 文本字段的imeAction更改为ImeAction.Done。您的函数应如以下代码段所示: 
EditNumberField(
   label = R.string.how_was_the_service,
   keyboardOptions = KeyboardOptions(
       keyboardType = KeyboardType.Number,
       imeAction = ImeAction.Done
   ),
   value = tipInput,
   onValueChange = { tipInput = it }
)
- 运行应用。它会显示 Next 和 Done 操作按钮,如下面这些图片所示:
 
  | 
  | 
- 输入任意账单金额并点击 Next 操作按钮,然后输入任意小费百分比并点击 Done 操作按钮。应用不会执行任何操作,因为您还未向这些按钮添加功能。您将在下一部分中添加这些功能。
 
5. 设置键盘操作
在本部分中,您将使用 KeyboardActions 类来实现将焦点移到下一个文本框并关闭键盘的功能,以改善用户体验。借助该类,开发者可以指定要触发哪些操作来响应软件键盘上的用户 IME(输入法)操作。举例来说,用户点击 Next 或 Done 操作按钮就属于 IME 操作。
您要实现以下操作:
- 对于 Next 操作:将焦点移到下一个文本字段(即 Tip % 文本框)。
 - Done 操作:关闭虚拟键盘。
 
- 在 
EditNumberField()函数中,添加名为focusManager的val变量,并为其赋予LocalFocusManager.current属性作为值: 
val focusManager = LocalFocusManager.current
LocalFocusManager 接口用于控制 Compose 中的焦点。您可以使用该变量将焦点移到文本框,以及从文本框清除焦点。
- 导入 
import androidx.compose.ui.platform.LocalFocusManager。 - 在 
EditNumberField()函数签名中,再添加一个KeyboardActions类型的keyboardActions形参: 
@Composable
fun EditNumberField(
   @StringRes label: Int,
   keyboardOptions: KeyboardOptions,
   keyboardActions: KeyboardActions,
   value: String,
   onValueChange: (String) -> Unit
) {
   //...
}
- 在 
EditNumberField()函数主体中,更新TextField()函数调用,将keyboardActions形参设置为传入的keyboardActions形参。 
@Composable
fun EditNumberField(
   //...
) {
   TextField(
       //...
       keyboardActions = keyboardActions
   )
}
现在,您可以自定义文本字段,并为每个操作按钮设定不同的功能。
- 在 
TipTimeScreen()函数调用中,更新第一个EditNumberField()函数调用,以添加keyboardActions具名形参作为新实参。为该形参分配值,即KeyboardActions( onNext ={ }): 
// Bill amount text field
EditNumberField(
   //...
   keyboardActions = KeyboardActions(
       onNext = { }
   ),
   //...
)
当用户按键盘上的 Next 操作按钮时,onNext 具名形参的 lambda 表达式便会运行。
- 定义 lambda,请求 
FocusManager将焦点向下移到下一个可组合项(即 Tip %)。在该 lambda 表达式中,对focusManager对象调用moveFocus()函数,然后传入FocusDirection.Down实参: 
// Bill amount text field
EditNumberField(
   label = R.string.bill_amount,
   keyboardOptions = KeyboardOptions(
       keyboardType = KeyboardType.Number,
       imeAction = ImeAction.Next
   ),
   keyboardActions = KeyboardActions(
       onNext = { focusManager.moveFocus(FocusDirection.Down) }
   ),
   value = amountInput,
   onValueChange = { amountInput = it }
)
moveFocus() 函数将焦点沿指定方向移动,在本示例中,就是向下移到 Tip % 文本字段。
- 导入以下代码:
 
import androidx.compose.ui.focus.FocusDirection
- 向 Tip % 文本字段添加类似实现。不同之处在于您需要定义一个 
onDone具名形参,而不是onNext。 
// Tip% text field
EditNumberField(
   //...
   keyboardActions = KeyboardActions(
       onDone = { }
   ),
   //...
)
- 用户输入自定义小费后,在键盘上执行 Done 操作应该会清除焦点,进而关闭键盘。定义 lambda,请求 
FocusManager清除焦点。在 lambda 表达式中,对focusManager对象调用clearFocus()函数: 
EditNumberField(
   label = R.string.how_was_the_service,
   keyboardOptions = KeyboardOptions(
       keyboardType = KeyboardType.Number,
       imeAction = ImeAction.Done
   ),
   keyboardActions = KeyboardActions(
       onDone = { focusManager.clearFocus() }),
   value = tipInput,
   onValueChange = { tipInput = it }
)
clearFocus() 函数会从获得焦点的组件中清除焦点。
- 运行应用。现在,键盘操作会更改获得焦点的组件,如下面的 GIF 所示:
 

6. 添加开关
开关可用来开启或关闭单个项的状态。切换开关具有两种状态,可让用户在两个选项之间进行选择。切换开关由滑块和滑道组成,如下面这些图片所示:
  | 
  | 
开关属于选择控件,可用于输入决策或声明偏好设置,例如下图所示的设置:

用户可以前后拖动滑块来选择所选选项,或者直接点按开关进行切换。下面的 GIF 显示了另一个切换开关示例,其中的 Visual options(视觉选项) 设置切换为 Dark mode(深色模式):

如需详细了解开关,请参阅开关文档。
您将使用 Switch 可组合项,以便用户选择是否将小费向上取整为最接近的整数,如下图所示:

为 Text 和 Switch 可组合项添加一个代码行:
- 在 
EditNumberField()函数后面,添加一个RoundTheTipRow()可组合函数,然后将默认的Modifier作为实参传入,类似于EditNumberField()函数: 
@Composable
fun RoundTheTipRow(modifier: Modifier = Modifier) {
}
- 实现 
RoundTheTipRow()函数,添加一个具有以下modifier的Row布局可组合项,以将子元素的宽度设置为屏幕上的最大值,居中对齐,并确保尺寸为48dp: 
Row(
   modifier = Modifier
       .fillMaxWidth()
       .size(48.dp),
   verticalAlignment = Alignment.CenterVertically
) {
}
- 导入以下代码:
 
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Size
- 在 
Row布局可组合项的 lambda 代码块中,添加一个使用R.string.round_up_tip字符串资源来显示Round up tip?字符串的Text可组合项: 
Text(text = stringResource(R.string.round_up_tip))
- 在 
Text可组合项后面,添加一个Switch可组合项,然后传递一个设置为roundUp的checked具名形参和一个设置为onRoundUpChanged的onCheckedChange具名形参。 
Switch(
    checked = roundUp,
    onCheckedChange = onRoundUpChanged,
)
下表列出了这些形参(与您为 RoundTheTipRow() 函数定义的形参相同)的相关信息:
参数  | 说明  | 
  | 指示开关是否处于选中状态。这是   | 
  | 点击开关时要调用的回调。  | 
- 导入以下代码:
 
import androidx.compose.material.Switch
- 在 
RoundTipRow()函数中,添加一个类型为Boolean的roundUp形参和一个接受Boolean且不返回任何内容的onRoundUpChangedlambda 函数: 
@Composable
fun RoundTheTipRow(
   roundUp: Boolean,
   onRoundUpChanged: (Boolean) -> Unit,
   modifier: Modifier = Modifier
)
这会提升开关的状态。
- 在 
Switch可组合项中,添加以下modifier,以将Switch可组合项与屏幕末端对齐: 
       Switch(
           modifier = modifier
               .fillMaxWidth()
               .wrapContentWidth(Alignment.End),
           //...
       )
- 导入以下代码:
 
import androidx.compose.foundation.layout.wrapContentWidth
- 在 
TipTimeScreen()函数中,为Switch可组合项的状态添加一个 var 变量。创建一个名为roundUp的var变量,将其设置为mutableStateOf(),并以false为默认实参。使用remember { }括住调用。 
fun TipTimeScreen() {
   //...
   var roundUp by remember { mutableStateOf(false) }
   //...
   Column(
       ...
   ) {
     //...
  }
}
这就是 Switch 可组合项状态的变量,默认状态为 false。
- 在 
TipTimeScreen()函数的Column代码块的 Tip % 文本字段后面,使用以下实参调用RoundTheTipRow()函数:一个设置为roundUp的roundUp具名形参,以及一个设置为 lambda 回调的onRoundUpChanged具名形参(用于更新roundUp值): 
@Composable
fun TipTimeScreen() {
   //...
   Column(
       ...
   ) {
       Text(
           ...
       )
       Spacer(...)
       EditNumberField(
           ...
       )
       EditNumberField(
           ...
       )
       RoundTheTipRow(roundUp = roundUp, onRoundUpChanged = { roundUp = it })
       Spacer(...)
       Text(
           ...
       )
   }
}
这会显示 Round up tip(小费向上取整) 行。
- 运行应用。应用会显示 Round up tip? 切换开关,但是该切换开关的滑块不太起眼,如下图所示:
 
  | 
  | 
在后续步骤中,您可以通过将滑块变成深灰色来提高其可见性。
- 在 
RoundTheTipRow()函数的Switch()可组合项中,添加colors具名形参。 - 将 
colors具名形参设置为SwitchDefaults.colors()函数,该函数接受设为Color.DarkGray实参的uncheckedThumbColor具名形参。 
Switch(
   //...
   colors = SwitchDefaults.colors(
       uncheckedThumbColor = Color.DarkGray
   )
)
- 导入以下代码:
 
import androidx.compose.material.SwitchDefaults
import androidx.compose.ui.graphics.Color
现在,RoundTheTipRow() 可组合函数应如以下代码段所示:
@Composable
fun RoundTheTipRow(roundUp: Boolean, onRoundUpChanged: (Boolean) -> Unit) {
   Row(
       modifier = Modifier
           .fillMaxWidth()
           .size(48.dp),
       verticalAlignment = Alignment.CenterVertically
   ) {
       Text(stringResource(R.string.round_up_tip))
       Switch(
           modifier = Modifier
               .fillMaxWidth()
               .wrapContentWidth(Alignment.End),
           checked = roundUp,
           onCheckedChange = onRoundUpChanged,
           colors = SwitchDefaults.colors(
               uncheckedThumbColor = Color.DarkGray
           )
       )
   }
}
- 运行应用。开关的滑块颜色变得不一样了,如下图所示:
 

- 输入账单金额和小费百分比,然后让 Round up tip? 切换开关处于开启状态。小费金额并没有取整,这是因为您还需要更新 
calculateTip()函数,您将在下一部分中执行此操作。 
更新 calculateTip() 函数以对小费进行取整
修改 calculateTip() 函数以接受 Boolean 变量,从而将小费向上取整为最接近的整数:
calculateTip()函数需要知道开关的状态(该状态为Boolean值)才能对小费进行向上取整。在calculateTip()函数中,添加类型为Boolean的roundUp形参:
private fun calculateTip(
   amount: Double,
   tipPercent: Double = 15.0,
   roundUp: Boolean
): String { 
   //...
}
- 在 
calculateTip()函数中的return语句前面,添加一个if()条件来检查roundUp值。如果roundUp为true,则定义一个tip变量并设置为kotlin.math.ceil()函数,然后将函数tip作为实参进行传递: 
if (roundUp)
   tip = kotlin.math.ceil(tip)
完成后的 calculateTip() 函数应如以下代码段所示:
private fun calculateTip(amount: Double, tipPercent: Double = 15.0, roundUp: Boolean): String {
   var tip = tipPercent / 100 * amount
   if (roundUp)
       tip = kotlin.math.ceil(tip)
   return NumberFormat.getCurrencyInstance().format(tip)
}
- 在 
TipTimeScreen()函数中,更新calculateTip()函数调用,然后传入roundUp形参: 
val tip = calculateTip(amount, tipPercent, roundUp)
- 运行应用。现在,它会将小费金额向上取整,如下面这些图片所示:
 
  | 
  | 
7. 获取解决方案代码
如需下载完成后的 Codelab 代码,您可以使用以下 Git 命令:
$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-tip-calculator.git
或者,您也可以下载 ZIP 文件形式的代码库,将其解压缩并在 Android Studio 中打开。
如果您想查看解决方案代码,请前往 GitHub 查看。
8. 总结
恭喜!您向 Tip Time 应用添加了自定义小费功能。现在,您的应用可让用户输入自定义小费百分比并对小费金额进行向上取整了。欢迎使用 #AndroidBasics 标签在社交媒体上分享您的作品!
  










1. 滑块
1. 滑块
