在 Jetpack Compose 中使用状态

在本 Codelab 中,您将了解 Jetpack Compose 如何使用和操作状态。

在深入了解这方面内容之前,先定义状态会非常有用。作为核心,应用中的状态是指可以随时间变化的任何值。这是一个非常宽泛的定义,从 Room 数据库到类的变量,全部涵盖在内。

所有 Android 应用都会向用户显示状态。下面是 Android 应用中的一些状态示例:

  1. 在无法建立网络连接时显示的信息提示控件。
  2. 博文和相关评论
  3. 在用户点击按钮时播放的波纹动画
  4. 用户可以在图片上绘制的贴纸

在本 Codelab 中,您将探索如何在使用 Jetpack Compose 时使用和考虑状态。为此,我们需要构建一个 TODO 应用。学完本 Codelab 后,您将构建一个有状态界面,其中会显示可修改的互动式 TODO 列表。

e1dae0ad7bb2e883.png

在下一部分中,您将了解单向数据流 - 这种设计模式是在使用 Compose 时了解如何显示和管理状态的核心。

学习内容

  • 什么是单向数据流
  • 如何考虑界面中的状态和事件
  • 如何在 Compose 中使用架构组件的 ViewModelLiveData 管理状态
  • Compose 如何使用状态绘制界面
  • 何时将状态移至调用方
  • 如何在 Compose 中使用内部状态
  • 如何使用 State<T> 将状态与 Compose 集成

所需条件

您将构建的内容

  • 在 Compose 中使用单向数据流的互动式 TODO 应用

要下载示例应用,您可以执行以下操作之一:

下载 ZIP 文件

…或从命令行使用下列命令克隆 GitHub 代码库:

git clone https://github.com/googlecodelabs/android-compose-codelabs.git
cd android-compose-codelabs/StateCodelab

您可以通过更改工具栏中的运行配置,随时在 Android Studio 中运行其中任一模块。

8a2e49d6d6d2609d.png

在 Android Studio 中打开项目

  1. 在“Welcome to Android Studio”窗口中,选择 1f5145c42df4129a.png Open an Existing Project
  2. 选择文件夹 [Download Location]/StateCodelab(提示:请务必选择包含 build.gradleStateCodelab 目录)
  3. Android Studio 导入项目后,请测试是否可以运行 startfinished 模块。

探索起始代码

起始代码包含四个文件包:

  • examples - 探索单向数据流概念的 activity 示例。您无需修改此文件包。
  • ui - 包含 Android Studio 在启动新的 Compose 项目时自动生成的主题。您无需修改此文件包。
  • util - 包含项目的帮助程序代码。您无需修改此文件包。
  • todo - 该文件包包含我们正在构建的待办事项界面的代码。您需要修改此文件包。

本 Codelab 将重点介绍 todo 文件包中的文件。在 start 模块中,有几个文件需要了解。

todo 文件包中提供的文件

  • Data.kt - 用于表示 TodoItem 的数据结构
  • TodoComponents.kt - 用于构建待办事项界面的可重用可组合项。您无需修改此文件。

您将在 todo 文件包中修改的文件

  • TodoActivity.kt - 结束本 Codelab 的学习后,Android activity 将使用 Compose 绘制待办事项界面。
  • TodoViewModel.kt - 与 Compose 集成的 ViewModel,用于构建待办事项界面。在结束本 Codelab 的学习后,您需要将其连接到 Compose 并对其进行扩展,以添加更多功能。
  • TodoScreen.kt - 您将在本 Codelab 中构建的待办事项界面的 Compose 实现。

界面更新循环

在介绍 TODO 应用之前,我们先了解一下使用 Android View 系统的单向数据流概念。

状态更新的原因是什么?在简介中,我们讨论了状态是会随着时间变化的任何值。这只是 Android 应用中状态的一部分。

在 Android 应用中,状态会更新以响应事件。事件是从应用外部生成的输入,如用户点按一个按钮调用 OnClickListenerEditText 调用 afterTextChanged,或加速度计发送一个新值。

所有 Android 应用都有核心界面更新循环,如下所示:

1be37cbec4304401.png

  • 事件 - 事件由用户或程序的其他部分生成
  • 更新状态 - 事件处理脚本会更改界面所使用的状态
  • 显示状态 - 界面会更新以显示新状态

管理 Compose 中的状态是为了了解状态和事件之间的交互方式。

非结构化状态

在开始介绍 Compose 之前,我们先来了解一下 Android View 系统中的事件和状态。针对“Hello, World”状态,我们将构建一个允许用户输入其姓名的 hello world Activity

451ab3e0e6cbce39.gif

我们编写该代码的方式之一是让事件回调直接在 TextView 中设置状态,而使用 ViewBinding 的代码可能如下所示:

HelloCodelabActivity**.kt**

class HelloCodelabActivity : AppCompatActivity() {

   private lateinit var binding: ActivityHelloCodelabBinding
   var name = ""

   override fun onCreate(savedInstanceState: Bundle?) {
       /* ... */
       binding.textInput.doAfterTextChanged {text ->
           name = text.toString()
           updateHello()
       }
   }

   private fun updateHello() {
       binding.helloText.text = "Hello, $name"
   }
}

此类代码可以使用,对于类似的小示例也很适用。但是,随着内容的增加,界面往往会变得难以管理。

当您向以如下方式构建的 activity 添加更多事件和状态时,可能会出现下面这几个问题:

  1. 测试 - 由于界面的状态与 Views 混合在一起,因此很难测试此代码。
  2. 部分状态更新 - 当界面上有更多事件时,很容易会忘记更新部分状态以响应事件。因此,用户看到的界面可能不一致或不正确。
  3. 部分界面更新 - 界面状态每次发生变化后,我们会手动更新界面,因此很容易忘记这一点。因此,用户可能会在界面中看到随机更新的过时数据。
  4. 代码复杂性 - 在此模式中编码时,很难提取一些逻辑。因此,往往难以看清和理解代码。

使用单向数据流

为了帮助解决这些非结构化状态问题,我们引入了包含 ViewModelLiveData 的 Android 架构组件。

借助 ViewModel,您可以从界面提取状态,并定义界面可调用以更新状态的状态。下面我们来看一下使用 ViewModel 编写的同一 activity。

67edaf41f5882075.png

HelloCodelabActivity.kt

class HelloCodelabViewModel: ViewModel() {

   // LiveData holds state which is observed by the UI
   // (state flows down from ViewModel)
   private val _name = MutableLiveData("")
   val name: LiveData<String> = _name

   // onNameChanged is an event we're defining that the UI can invoke
   // (events flow up from UI)
   fun onNameChanged(newName: String) {
       _name.value = newName
   }
}

class HelloCodeLabActivityWithViewModel : AppCompatActivity() {
   private val helloViewModel by viewModels<HelloCodelabViewModel>()

   override fun onCreate(savedInstanceState: Bundle?) {
       /* ... */

       binding.textInput.doAfterTextChanged {
           helloViewModel.onNameChanged(it.toString())
       }

       helloViewModel.name.observe(this) { name ->
           binding.helloText.text = "Hello, $name"
       }
   }
}

在此示例中,我们将状态从 Activity 移到了 ViewModel。在 ViewModel 中,状态由 LiveData 表示。LiveData 是一种可观察状态容器,这意味着它可让任何人观察状态的变化。然后,我们在界面中使用 observe 方法,以便在状态变化时更新界面。

ViewModel 也公开一个事件:onNameChanged。界面会调用此事件来响应用户事件,例如,每当 EditText 的文本发生变化时会发生什么。

回到前面探讨过的界面更新循环,我们可以看到此 ViewModel 是如何与事件和状态配合工作的。

  • 事件 - 当文本输入发生变化时,界面会调用 onNameChanged
  • 更新状态 - onNameChanged 会进行处理,然后设置 _name 的状态
  • 显示状态 - 调用 name 的观察器,用于通知界面状态的变化

通过以这种方式构建代码,我们可以将事件“向上”流动到 ViewModel。然后,为了响应事件,ViewModel 将进行一些处理,而且可能会更新状态。状态更新后,会“向下”流动到 Activity

状态从 ViewModel 向下流动到 activity,而事件从 activity 向上流动到 ViewModel。

这种模式称为单向数据流。单向数据流是一种状态向下流动而事件向上流动的设计。以这种方式构建代码具备以下优势:

  • 可测试性 - 将状态与显示状态的分离开来,更方便对 ViewModel 和 activity 进行测试
  • 状态封装 - 因为状态只能在一个位置 (ViewModel) 进行更新,因此不太可能在界面增加时时引入部分状态更新的错误
  • 界面一致性 - 通过使用可观察的状态存储器,所有状态更新都会立即反映在界面中。

因此,虽然此方法确实使代码量有所增加,但在使用单向数据流处理复杂状态和事件,它往往更容易也更可靠。

在下一部分中,我们将了解如何将单向数据流与 Compose 配合使用。

在上一部分中,我们探讨了使用 ViewModelLiveData 在 Android View 系统中实现单向数据流。现在我们将讨论 Compose,并探索如何使用 ViewModels 在 Compose 中使用单向数据流。

完成此部分的学习后,您将构建如下所示的界面:

a10327d5d1f3ae70.png

探索 TodoScreen 可组合项

您下载的代码包含几个可组合项,在本 Codelab 的学习过程中,您将使用并修改这些可组合项。

请打开 TodoScreen.kt 并查看现有的 TodoScreen 可组合项:

TodoScreen.kt

@Composable
fun TodoScreen(
   items: List<TodoItem>,
   onAddItem: (TodoItem) -> Unit,
   onRemoveItem: (TodoItem) -> Unit
) {
   /* ... */
}

如需查看该可组合项显示的内容,请点击右上角的拆分图标 fcb8671958ce4366.png,使用 Android Studio 中的预览窗格查看。

90a15e40ba2b514f.png

该可组合项显示了一个可修改的 TODO 列表,但它本身没有任何状态。请注意,状态是指可以变化的任何值,但无法修改 TodoScreen 的所有参数。

  • items - 界面上显示的不可变项的列表
  • onAddItem - 在用户请求添加项时发生的事件
  • onRemoveItem - 在用户请求删除项时发生的事件

实际上,该可组合项是无状态的。它只会显示传入的项列表,而且无法直接修改列表。相反,系统会向其传递两个可以请求更改的事件:onRemoveItemonAddItem

这就引出了一个问题:如果可组合项是无状态的,那它如何才能显示可修改的列表?为实现此目的,它会使用一种称为状态提升的技术。状态提升是一种将状态上移以使组件变为无状态的模式。无状态组件更容易测试,发生的错误往往更少,并且更有可能重复使用。

事实证明,这些参数的组合使得调用方能够从此可组合项中提升状态。为了了解具体的工作原理,我们来探索此可组合项的界面更新循环。

  • 事件 - 当用户请求添加或删除项时,TodoScreen 会调用 onAddItemonRemoveItem
  • 更新状态 - TodoScreen 的调用方可以通过更新状态来响应这些事件
  • 显示状态 - 状态更新后,系统将使用新的 items 再次调用 TodoScreen,而且后者可以在界面上显示它们

调用方负责确定保持此状态的位置和方式。不过,它可以合理地存储 items,例如,存储在内存中或从 Room 数据库中读取。TodoScreen 与状态的管理方式是完全解耦的。

定义 TodoActivityScreen 可组合项

请打开 TodoViewModel.kt,并找到现有的 ViewModel,它用于定义一个状态变量和两个事件。

TodoViewModel.kt

class TodoViewModel : ViewModel() {

   // state: todoItems
   private var _todoItems = MutableLiveData(listOf<TodoItem>())
   val todoItems: LiveData<List<TodoItem>> = _todoItems

   // event: addItem
   fun addItem(item: TodoItem) {
        /* ... */
   }

   // event: removeItem
   fun removeItem(item: TodoItem) {
        /* ... */
   }
}

我们希望使用此 ViewModel 来提升 TodoScreen 中的状态。完成操作后,会创建如下所示的单向数据流设计

58baca1f648c1a64.png

如需将 TodoScreen 集成到 TodoActivity 中,请打开 TodoActivity.kt 并定义新的 @Composable 函数 TodoActivityScreen(todoViewModel: TodoViewModel),然后从 onCreate 中的 setContent 调用该函数。

在此部分中的其余时间里,我们将逐步构建 TodoActivityScreen。首先,您可以使用虚构状态和事件调用 TodoScreen,如下所示:

TodoActivity.kt

import androidx.compose.runtime.Composable

class TodoActivity : AppCompatActivity() {

   private val todoViewModel by viewModels<TodoViewModel>()

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContent {
           StateCodelabTheme {
               Surface {
                   TodoActivityScreen(todoViewModel)
               }
           }
       }
   }
}

@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
   val items = listOf<TodoItem>() // in the next steps we'll complete this
   TodoScreen(
       items = items,
       onAddItem = { }, // in the next steps we'll complete this
       onRemoveItem = { } // in the next steps we'll complete this
   )
}

该可组合项将在 ViewModel 中存储的状态与项目中已定义的 TodoScreen 可组合项之间起到桥接作用。您可以更改 TodoScreen 以直接接受 ViewModel,不过这样一来 TodoScreen 的可重用性会降低。优先使用较为简单的参数(如 List<TodoItem>)时,TodoScreen 不会与状态提升的特定位置相关联。

如果您现在运行该应用,会看到一个按钮,但点击该按钮后系统不会执行任何操作。这是因为我们尚未将 ViewModel 连接到 TodoScreen

ebb21b54a46d8449.png

向上流动事件

现在,我们已具备所需的所有组件 - ViewModel、桥接可组合项 TodoActivityScreen,以及 TodoScreen。接下来让我们所有组件连接在一起,以使用单向数据流显示动态列表。

TodoActivityScreen 中,从 ViewModel 中传递 addItemremoveItem

TodoActivity.kt

@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
   val items = listOf<TodoItem>()
   TodoScreen(
       items = items,
       onAddItem = { todoViewModel.addItem(it) },
       onRemoveItem = { todoViewModel.removeItem(it) }
   )
}

TodoScreen 调用 onAddItemonRemoveItem 时,可以将调用传递给 ViewModel 上的正确事件。

向下传递状态

我们已连接单向数据流的事件,现在需要向下传递状态。

请修改 TodoActivityScreen,以使用 observeAsState 观察 todoItems LiveData

TodoActivity.kt

import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState

@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
   val items: List<TodoItem> by todoViewModel.todoItems.observeAsState(listOf())
   TodoScreen(
       items = items,
       onAddItem = { todoViewModel.addItem(it) },
       onRemoveItem = { todoViewModel.removeItem(it) }
   )
}

此行将观察 LiveData,而且让我们可以直接将当前值用作 List<TodoItem>

这一行中包含许多内容,所以让我们对它进行拆分:

  • val items: List<TodoItem> 声明了类型为 List<TodoItem> 的变量 items
  • todoViewModel.todoItems 是来自 ViewModelLiveData<List<TodoItem>
  • .observeAsState 会观察 LiveData<T> 并将其转换为 State<T> 对象,让 Compose 可以响应值的变化
  • listOf() 是一个初始值,用于避免在初始化 LiveData 之前可能出现 null 结果。如果未传递,items 会是 List<TodoItem>?,可为 null。
  • by 是 Kotlin 中的属性委托语法,使我们可以自动将 State<List<TodoItem>>observeAsState 解封为标准 List<TodoItem>

再次运行应用

再次运行应用后,您会看到一个动态更新的列表。点击底部的按钮即可添加新项,点击某个项即可删除。

a10327d5d1f3ae70.png

在此部分中,我们探讨了如何使用 ViewModels 在 Compose 中构建单向数据流设计。我们还了解了如何通过状态提升技术使用无状态可组合项显示有状态界面。然后,我们继续探索了如何根据状态事件考虑动态界面。

在下一部分中,我们将探讨向可组合函数中添加内存。

现在,我们已经了解了如何结合使用 Compose 与 ViewModel 构建单向数据流。接下来,我们将探讨 Compose 如何在内部与状态交互。

在上一部分中,您已经了解了 Compose 如何通过再次调用可组合项来更新界面。它利用的是称为重组的过程。我们还调用了 TodoScreen 来显示动态列表。

在此部分和下一部分中,我们将探讨如何构建有状态可组合项。

在此部分中,我们将探讨如何将内存添加到可组合函数中。在下一部分中,我们将在此构建块中向 Compose 添加状态。

分散设计

设计师提供的模拟

a206dbaf4be66703.png

针对此部分,我们假设您团队中的一位新设计师为您提供了遵照最新设计趋势的模拟 - 散乱设计。散乱设计的核心原则是采用良好的设计,并随意添加看似随机的变化,让设计变得“有趣”。

在此设计中,每个图标的色调调节为介于 0.3 和 0.9 之间的随机 Alpha 值。

向可组合项添加随机值

首先,请打开 TodoScreen.kt 并找到 TodoRow 可组合项。该可组合项描述了待办事项列表中的一行。

定义值为 randomTint() 的新 val iconAlpha。这是我们设计师要求的介于 0.3 和 0.9 之间的浮点数。然后,设置图标的色调。

TodoScreen.kt

import androidx.compose.material.LocalContentColor

@Composable
fun TodoRow(todo: TodoItem, onItemClicked: (TodoItem) -> Unit, modifier: Modifier = Modifier) {
   Row(
       modifier = modifier
           .clickable { onItemClicked(todo) }
           .padding(horizontal = 16.dp, vertical = 8.dp),
       horizontalArrangement = Arrangement.SpaceBetween
   ) {
       Text(todo.task)
       val iconAlpha = randomTint()
       Icon(
           imageVector = todo.icon.imageVector,
           tint = LocalContentColor.current.copy(alpha = iconAlpha),
           contentDescription = stringResource(id = todo.icon.contentDescription)
       )
   }
}

如果您再次检查预览,就会看到现在图标具有随机的色调颜色。

f668a61da50cd417.png

探索重组

再次运行应用以试用新的散乱设计,您会立即注意到色调似乎一直在变化。您的设计师告诉过我们会有一些随机的变化,但这样的变化有点过度了。

图标在列表更改时色调发生变化的应用

86dbbb4eefbc61c.gif

这是怎么回事?原来,每当列表发生变化时,重组过程都会为界面上的每一行重新调用 randomTint

重组是使用新的输入重新调用可组合项以更新 Compose 树的过程。在这种情况下,当使用新列表再次调用 TodoScreen 时,LazyColumn 会重组界面上的所有子项。这样一来,接下来会再次调用 TodoRow,从而生成新的随机色调。

Compose 会生成一个树,但与您所了解的 Android View 系统中的界面树略有不同。Compose 不会生成界面微件树,而是生成可组合项的树。我们可以直观呈现 TodoScreen,如下所示:

TodoScreen 树

6f5faa4342c63d88.png

Compose 首次运行组合时,会为每个被调用的可组合项构建一个树。然后,在重组期间,它会使用调用的新可组合项更新树。

每次 TodoRow 重组时,图标都会更新,因为 TodoRow 具有一个隐藏的附带效应。附带效应是指在可组合函数运行范围之外发生的所有变化。

调用 Random.nextFloat() 会更新伪随机数生成器中使用的内部随机变量。每次您请求随机数时,Random 都会以这种方式返回不同的值。

将内存引入可组合函数

我们不希望每当 TodoRow 重组时色调都会变化。为此,我们需要一个位置来记住我们在上一次合成中使用的色调。Compose 使我们可以将值存储在组合树中,因此我们可以更新 TodoRow,将 iconAlpha 存储在组合树中。

请修改 TodoRow 并使用 rememberrandomTint 的调用括起来,如下所示:

TodoScreen.kt

val iconAlpha: Float = remember(todo.id) { randomTint() }
Icon(
   imageVector = todo.icon.imageVector,
   tint = LocalContentColor.current.copy(alpha = iconAlpha),
   contentDescription = stringResource(id = todo.icon.contentDescription)
)

查看 TodoRow 的新 Compose 树时,您会看到 iconAlpha 已添加到 Compose 树中:

使用 remember 的 TodoRow 树

该图表会在 Composer 树中将 iconAlpha 显示为 TodoRow 的新子项。

如果您现在再次运行应用,会看到每次列表发生变化时,色调不会更新。相反,重组时,系统会返回 remember 存储的先前的值。

如果仔细查看要记住的调用,您会发现我们将 todo.id 作为 key 参数传递。

remember(todo.id) { randomTint() }

remember 调用包含两部分:

  1. key arguments - 这次 remember 调用使用的“键”,即在圆括号中传递的部分。在此示例中,我们传递 todo.id 作为键。
  2. calculation - 一个 lambda,用于计算要记住的新值,传入尾随 lambda。在本例中,我们使用 randomTint() 计算一个随机值。

第一次组合时,remember 会始终调用 randomTint 并记住下次重组的结果。它还会跟踪已传递的 todo.id。然后,在重组期间,它会跳过调用 randomTint 并返回记住的值,除非有新的 todo.id 传递给 TodoRow

可组合项的重组必须具有幂等性。使用 rememberrandomTint 调用括起来,重组后,即可跳过随机调用,除非待办事项发生变化。因此,TodoRow 没有任何附带效应,每次重组时都使用相同的输入,始终生成相同的结果,并且具有幂等性。

已记住的值设为可控制

如果您现在运行应用,会看到它在每个图标上显示随机的色调。您的设计师非常高兴,因为这样的设计遵循了散乱设计原则,并且批准推出该设计。

不过,在您执行此操作之前,需要进行一项细微的代码更改。目前,TodoRow 的调用方无法指定色调。他们可能想这样做的原因多种多样,例如产品副总裁注意到此界面,而要求在发布应用之前使用修补程序移除散乱内容。

如需允许调用方控制此值,只需将 remember 调用移至新 iconAlpha 参数的默认参数即可。

@Composable
fun TodoRow(
   todo: TodoItem,
   onItemClicked: (TodoItem) -> Unit,
   modifier: Modifier = Modifier,
   iconAlpha: Float = remember(todo.id) { randomTint() }
) {
   Row(
       modifier = modifier
           .clickable { onItemClicked(todo) }
           .padding(horizontal = 16.dp)
           .padding(vertical = 8.dp),
       horizontalArrangement = Arrangement.SpaceBetween
   ) {
       Text(todo.task)
       Icon(
            imageVector = todo.icon.imageVector,
            tint = LocalContentColor.current.copy(alpha = iconAlpha),
            contentDescription = stringResource(id = todo.icon.contentDescription)
        )
   }
}

现在,默认情况下,调用方会获得相同的行为 - 即 TodoRow 会计算 randomTint。不过,他们可以指定任意 Alpha 值。允许调用方控制 alphaTint 可以提高此可组合项的可重用性。在另一个界面上,设计师可能想显示 0.7 Alpha 值的所有图标。

remember 用法也有一个非常细微的错误。如果您反复点击“添加随机待办事项”然后滚动,以尝试添加足够的待办事项行,让一些行滚动超出界面范围。滚动时,您会发现图标每次滚动回到界面时,Alpha 值都会发生变化。

在接下来的部分中,我们将探讨状态和状态提升,以便为您提供解决此类错误所需的工具。

在上一部分中,我们了解了如何为可组合函数提供内存,现在我们将探索使用该内存向可组合项添加状态。

待办事项输入(状态:已展开)3e3910f52bb356b9.png

待办事项输入(状态:已收起) f00186b8b5b43ea8.png

我们的设计师已经散乱设计转向 Material 之后的设计。这种新的待办事项输入设计与可收起标题占用的空间相同,而且有两种主要状态:已展开和已收起。只要文字不为空,系统就会显示展开版本。

要构建此设置,需要先构建文本和按钮,然后了解如何添加自动隐藏图标。

在界面中修改文本是有状态的。用户每次输入字符时(甚至在更改所选内容时),当前显示的文本都会更新。在 Android View 系统中,此状态是 EditText 的内置状态,并通过 onTextChanged 监听器公开。但由于 Compose 是专为单向数据流设计的,因此它并不适用。

Compose 中的 TextField 是一个无状态可组合项。与显示不断变化的待办事项列表的 TodoScreen 类似,TextField 仅显示您告知的内容,并且在用户输入内容时发布事件。

创建有状态 TextField 可组合项

为了探索 Compose 中的状态,我们需要构建一个有状态组件,以显示可修改的 TextField

首先,请打开 TodoScreen.kt 并添加以下函数

TodoScreen.kt

import androidx.compose.runtime.mutableStateOf

@Composable
fun TodoInputTextField(modifier: Modifier) {
   val (text, setText) = remember { mutableStateOf("") }
   TodoInputText(text, setText, modifier)
}

此函数使用 remember 向自身添加内存,然后在内存中存储 mutableStateOf,以创建 MutableState<String>,这是一种提供可观察状态容器的内置 Compose 类型。

由于要立即将值和 setter 事件传递给 TodoInputText,因此我们将 MutableState 对象解构为一个 getter 和一个 setter。

就是这么简单!我们在 TodoInputTextField 中创建了一个内置状态。

如需查看实际运用效果,请定义另一个用于显示 TodoInputTextFieldButton 的可组合项 TodoItemInput

TodoScreen.kt

import androidx.compose.ui.Alignment

@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
   // onItemComplete is an event will fire when an item is completed by the user
   Column {
       Row(Modifier
           .padding(horizontal = 16.dp)
           .padding(top = 16.dp)
       ) {
           TodoInputTextField(Modifier
               .weight(1f)
               .padding(end = 8.dp)
           )
           TodoEditButton(
               onClick = { /* todo */ },
               text = "Add",
               modifier = Modifier.align(Alignment.CenterVertically)
           )
       }
   }
}

TodoItemInput 只有一个参数,即事件 onItemComplete。当用户完成 TodoItem 时,系统就会触发事件。这种传递 lambda 的模式是在 Compose 中定义自定义事件的主要方式。

此外,请更新 TodoScreen 可组合项,从而在项目中已定义的后台 TodoItemInputBackground 中调用 TodoItemInput

TodoScreen.kt

@Composable
fun TodoScreen(
   items: List<TodoItem>,
   onAddItem: (TodoItem) -> Unit,
   onRemoveItem: (TodoItem) -> Unit
) {
   Column {
       // add TodoItemInputBackground and TodoItem at the top of TodoScreen
       TodoItemInputBackground(elevate = true, modifier = Modifier.fillMaxWidth()) {
           TodoItemInput(onItemComplete = onAddItem)
       }
...

试用 TodoItemInput

我们刚刚为文件定义了一个主界面可组合项,因此最好为其添加一个 @Preview。这样一来,我们就可以单独探索这个可组合项,而且可以让此文件的读者能够快速预览它。

TodoScreen.kt 中,在底部添加新的预览函数:

TodoScreen.kt

@Preview
@Composable
fun PreviewTodoItemInput() = TodoItemInput(onItemComplete = { })

现在,您可以在互动式预览或模拟器中运行该可组合项,单独调试此可组合项。

执行此操作时,您会看到它正确显示了一个可修改文本字段,允许用户修改文本。每当用户输入字符时,状态都会更新,这会触发重组,更新向用户显示的 TextField

当前显示的是在互动状态下运行的 PreviewTodoItemInput。

将按钮设置为点击一下即可添加项目

现在,我们要将“Add”按钮设置为可实际添加 TodoItem。为此,我们需要从 TodoInputTextField 访问 text

如果您查看 TodoItemInput 的组合树的一部分,可以看到我们是在 TodoInputTextField 内部存储文本状态。

TodoItemInput 组合树(隐藏内置可组合项)

树:包含子 TodoInputTextField 和 TodoEditButton 的 TodoItemInput。状态文本是 TodoInputTextField 的子项。

此结构不允许我们连接 onClick,因为 onClick 需要访问 text 的当前值。我们想将 text 状态公开给 TodoItemInput,同时使用单向数据流。

单向数据流既适用于高级架构,也适用于使用 Jetpack Compose 的单个可组合项的设计。现在,我们希望使事件始终向上流动,而状态始终向下流动。

这意味着我们需要让状态从 TodoItemInput 向下流动,而事件向上流动。

TodoItemInput 的单向数据流图

图表:TodoItemInput 位于顶部,状态向下流动到 TodoInputTextField。事件从 TodoInputTextField 向上流动到 TodoItemInput。

为实现此目的,我们需要将状态从子可组合项 TodoInputTextField 移到父级 TodoItemInput

包含状态提升的 TodoItemInput 组合树(隐藏内置可组合项)

866bd1a19a36fbab.png

这种模式称为状态提升。我们将从可组合项中“提升”状态,以使其变为无状态。状态提升是在 Compose 中构建单向数据流设计的主要模式。

如需提升状态,可以将可组合项的任何内置状态 T 重构为 (value: T, onValueChange: (T) -> Unit) 参数对。

请修改 TodoInputTextField 以添加 (value, onValueChange) 参数,提升状态:

TodoScreen.kt

// TodoInputTextField with hoisted state

@Composable
fun TodoInputTextField(text: String, onTextChange: (String) -> Unit, modifier: Modifier) {
   TodoInputText(text, onTextChange, modifier)
}

此代码会向 TodoInputTextField 添加 valueonValueChange 参数。值参数为 textonValueChange 参数为 onTextChange

然后,由于状态现已提升,因此我们从 TodoInputTextField 中移除了已记住的状态。

以这种方式提升的状态具有一些重要的属性:

  • 单一可信来源 - 通过移动状态而不是复制状态,我们确保文本只有一个可信来源。这有助于避免 bug。
  • 封装 - 只有 TodoItemInput 能够修改状态,而其他组件可以向 TodoItemInput 发送事件。以这种方式提升时,只有一个可组合项是有状态的,即使有多个可组合项使用状态也是如此。
  • 可共享 - 提升的状态可以作为不可变值与多个可组合项共享。我们需要同时在 TodoInputTextFieldTodoEditButton 中使用此状态。
  • 可拦截 - TodoItemInput 可以在更改状态之前决定是忽略还是修改事件。例如,TodoItemInput 可以在用户输入内容时将 :emoji-codes: 格式转换为表情符号。
  • 解耦 - TodoInputTextField 的状态可存储在任何位置。例如,我们可以选择通过 Room 数据库支持此状态,每当输入字符时,该数据库都会更新,而不会修改 TodoInputTextField

现在,在 TodoItemInput 中添加状态并将其传递给 TodoInputTextField

TodoScreen.kt

@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
   val (text, setText) = remember { mutableStateOf("") }
   Column {
       Row(Modifier
           .padding(horizontal = 16.dp)
           .padding(top = 16.dp)
       ) {
           TodoInputTextField(
               text = text,
               onTextChange = setText,
               modifier = Modifier
                   .weight(1f)
                   .padding(end = 8.dp)
           )
           TodoEditButton(
               onClick = { /* todo */ },
               text = "Add",
               modifier = Modifier.align(Alignment.CenterVertically)
           )
       }
   }
}

现在我们已提升状态,可以使用当前文本值来驱动 TodoEditButton 的行为。完成回调并 enable 按钮(仅在文本不是根据设计显示空白时):

TodoScreen.kt

// edit TodoItemInput
TodoEditButton(
   onClick = {
       onItemComplete(TodoItem(text)) // send onItemComplete event up
       setText("") // clear the internal text
   },
   text = "Add",
   modifier = Modifier.align(Alignment.CenterVertically),
   enabled = text.isNotBlank() // enable if text is not blank
)

我们在两个不同的可组合项中使用相同的状态变量 text。可以像这样通过提升状态来共享状态。此外,我们已经成功执行此操作,只将 TodoItemInput 构建为有状态可组合项。

再次运行

再次运行应用后,您会看到,现在可以添加待办事项了!恭喜,您已学会如何将状态添加到可组合项以及如何提升状态!

fd3b6aef10c95d9d.png

代码清理

在继续操作之前,请内嵌 TodoInputTextField。我们刚刚在此部分中添加了该代码,以探索状态提升。如果您查看 Codelab 提供的 TodoInputText 代码,会发现它已按照我们在此部分讨论过的模式提升状态。

完成上述操作后,您的 TodoItemInput 应如下所示:

TodoScreen.kt

@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
   val (text, setText) = remember { mutableStateOf("") }
   Column {
       Row(Modifier
           .padding(horizontal = 16.dp)
           .padding(top = 16.dp)
       ) {
           TodoInputText(
               text = text,
               onTextChange = setText,
               modifier = Modifier
                   .weight(1f)
                   .padding(end = 8.dp)
           )
           TodoEditButton(
               onClick = {
                   onItemComplete(TodoItem(text))
                   setText("")
               },
               text = "Add",
               modifier = Modifier.align(Alignment.CenterVertically),
               enabled = text.isNotBlank()
           )
       }
   }
}

在下一部分中,我们将继续构建这种设计并添加图标。您将使用此部分中介绍的工具提升状态,并使用单向数据流构建互动式界面。

在上一部分中,您学习了如何向可组合项添加状态,以及如何使用状态提升构建使用无状态状态的可组合项。

接下来,我们将探讨如何基于状态构建动态界面。回看设计师提供的模拟,当文本不为空时,它应该会显示相应的图标行。

待办事项输入(状态:已展开 - 非空白文本) 3e3910f52bb356b9.png

待办事项输入(状态:已收起 - 空白文本) f00186b8b5b43ea8.png

从状态派生 iconsVisible

请打开 TodoScreen.kt 并创建一个新的状态变量,用于保存当前选定的 icon,同时创建一个新的 val iconsVisible(当文本不为空时,它的值为 true)。

TodoScreen.kt

@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
   val (text, setText) = remember { mutableStateOf("") }
   val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default)}
   val iconsVisible = text.isNotBlank()
    // ...

我们添加了第二个状态 icon,它包含当前选定的图标。

iconsVisible 不会向 TodoItemInput 添加新状态。TodoItemInput 无法直接对其进行更改。相反,它完全基于 text 的值。无论重组时 text 的值是多少,系统都会相应地设置 iconsVisible,而且我们可以使用该值来显示正确的界面。

我们可以向 TodoItemInput 添加另一种状态,以控制图标何时可见,但如果您仔细查看规范,便会发现可见性完全基于输入的文本。如果我们构建了两个状态,那么同步就会非常容易。

但我们倾向于使用单一的可信来源。在该可组合项中,只需将 text 设为状态,而 iconsVisible 则可以基于 text

请继续修改 TodoItemInput,以基于 iconsVisible 的值显示 AnimatedIconRow。如果 iconsVisible 为 true,则显示 AnimatedIconRow;如果为 false,则使用 16.dp 显示分隔符。

TodoScreen.kt

import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height

@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
   val (text, setText) = remember { mutableStateOf("") }
   val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default)}
   val iconsVisible = text.isNotBlank()
   Column {
       Row( /* ... */ ) {
           /* ... */
       }
       if (iconsVisible) {
           AnimatedIconRow(icon, setIcon, Modifier.padding(top = 8.dp))
       } else {
           Spacer(modifier = Modifier.height(16.dp))
       }
   }
}

如果您现在再次运行该应用,会看到在您输入文本时,这些图标会以动画形式呈现。

现在我们要基于 iconsVisible 的值动态更改组合树。下图显示了两种状态的组合树。

这种条件式显示逻辑等同于 Android View 系统中的可见性。

iconsVisible 更改时的 TodoItemInput 组合树

ceb75cf0f13a1590.png

如果您再次运行应用,会看到图标行显示正确,但如果点击“添加”图标,图标行不会变为已添加的待办事项行。这是因为我们尚未更新事件以传递新的图标状态。接下来,我们就要执行此操作。

更新事件以使用图标

请修改 TodoItemInput 中的 TodoEditButton,以在 onClick 监听器中使用新的 icon 状态。

TodoScreen.kt

TodoEditButton(
   onClick = {
       onItemComplete(TodoItem(text, icon))
       setIcon(TodoIcon.Default)
       setText("")
   },
   text = "Add",
   modifier = Modifier.align(Alignment.CenterVertically),
   enabled = text.isNotBlank()
)

您可以直接在 onClick 监听器中使用新的 icon 状态。此外,当用户输入 TodoItem 时,我们也会将其重置为默认值。

如果您现在运行应用,会看到带有动画按钮的互动式待办事项。太棒了!

662597dcc5f4152d.gif

使用 imeAction 完成设计

当您向设计师展示该应用时,他们会告诉您,该应用应通过键盘的 IME 操作提交待办事项。右下角的蓝色按钮便具有这种用途:

附带 ImeAction.Done 的 Android 键盘

5516bd6c05ab5be9.png

TodoInputText 可让您通过其 onImeAction 事件响应 imeAction。

我们希望 onImeAction 的行为与 TodoEditButton 完全相同。我们可以复制此代码,但随着时间的推移,会很难对其进行维护,因为只更新其中一个事件非常容易。

让我们将事件提取到变量中,以便同时将其用于 TodoInputTextonImeActionTodoEditButtononClick

请再次修改 TodoItemInput,以声明处理用户执行的提交操作的新 lambda 函数 submit。然后,将新定义的 lambda 函数传递给 TodoInputTextTodoEditButton

TodoScreen.kt

@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
   val (text, setText) = remember { mutableStateOf("") }
   val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default)}
   val iconsVisible = text.isNotBlank()
   val submit = {
       onItemComplete(TodoItem(text, icon))
       setIcon(TodoIcon.Default)
       setText("")
   }
   Column {
       Row(Modifier
           .padding(horizontal = 16.dp)
           .padding(top = 16.dp)
       ) {
           TodoInputText(
               text = text,
               onTextChange = setText,
               modifier = Modifier
                   .weight(1f)
                   .padding(end = 8.dp),
               onImeAction = submit // pass the submit callback to TodoInputText
           )
           TodoEditButton(
               onClick = submit, // pass the submit callback to TodoEditButton
               text = "Add",
               modifier = Modifier.align(Alignment.CenterVertically),
               enabled = text.isNotBlank()
           )
       }
       if (iconsVisible) {
           AnimatedIconRow(icon, setIcon, Modifier.padding(top = 8.dp))
       } else {
           Spacer(modifier = Modifier.height(16.dp))
       }
   }
}

如果需要,您可以从此函数中进一步提取逻辑。但是,该可组合项看起来功能非常出色,所以我们就不再做这方面的介绍了。

这是 Compose 的一大优势 - 因为您将使用 Kotlin 语言声明界面,让您可以构建所需的任何抽象化,实现代码的解耦和可重用性。

如果要使用键盘处理操作,可以使用 TextField 提供的两个参数:

  • keyboardOptions - 用于启用“完成”IME 操作的显示
  • keyboardActions - 用于指定在响应特定 IME 操作时触发的操作 - 在本例中,当用户按下“完成”后,我们希望调用 submit 并隐藏键盘。

为了控制软件键盘,我们需要使用 LocalSoftwareKeyboardController.current。由于这是一个实验性 API,因此必须使用 @OptIn(ExperimentalComposeUiApi::class) 为该函数添加注解。

@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun TodoInputText(
    text: String,
    onTextChange: (String) -> Unit,
    modifier: Modifier = Modifier,
    onImeAction: () -> Unit = {}
) {
    val keyboardController = LocalSoftwareKeyboardController.current
    TextField(
        value = text,
        onValueChange = onTextChange,
        colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Transparent),
        maxLines = 1,
        keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
        keyboardActions = KeyboardActions(onDone = {
            onImeAction()
            keyboardController?.hide()
        }),
        modifier = modifier
    )
}

请再次运行应用以试用新图标

再次运行应用后,您会看到图标在文本更改状态时自动显示或隐藏。您还可以更改图标选择内容。点击“添加”按钮时,您会看到系统基于输入的值生成一个新的待办事项。

恭喜,您已了解 Compose 中的状态、状态提升以及如何基于状态构建动态界面。

在接下来的部分中,我们将探讨如何考虑构建与状态交互的可重用组件。

您的设计师目前采用的是一种新的设计趋势。本周的设计摒弃了散乱界面和 Material 之后的设计,遵循的是“新现代互动式”设计趋势。您问他们这是怎么回事,他们的回答有点令人困惑,而且涉及到了表情符号。但不管怎么样,这些都只是模拟。

用于修改模式的模拟

修改模式会重用与输入模式相同的界面,但会将编辑器嵌入到列表中。

设计师表示,模拟会重用与输入相同的界面,不过按钮改为了保存和完成表情符号。

在最后一部分中,我们将 TodoItemInput 保留为有状态可组合项。这种设置非常适用于输入待办事项,但现在它是一个编辑器,需要支持状态提升。

在这一部分中,您将学习如何从有状态可组合项中提取状态,使其成为无状态可组合项。这样,您就可以针对添加和修改待办事项重用同一个可组合项。

将 TodoItemInput 转换为无状态可组合项

首先,我们需要从 TodoItemInput 提升状态。那我们把它放在什么位置呢?我们可以将其直接放在 TodoScreen 中 - 但是它已经非常适用于内置状态和已完成的事件。而且,我们不希望更改该 API。

我们可以改为将可组合项拆分为两个 - 一个是有状态的,另一个是无状态的。

请打开 TodoScreen.kt 并将 TodoItemInput 拆分为两个可组合项,然后将有状态可组合项重命名为 TodoItemEntryInput,因为这只适用于输入新的 TodoItems

TodoScreen.kt

@Composable
fun TodoItemEntryInput(onItemComplete: (TodoItem) -> Unit) {
   val (text, setText) = remember { mutableStateOf("") }
   val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default)}
   val iconsVisible = text.isNotBlank()
   val submit = {
       onItemComplete(TodoItem(text, icon))
       setIcon(TodoIcon.Default)
       setText("")
   }
   TodoItemInput(
       text = text,
       onTextChange = setText,
       icon = icon,
       onIconChange = setIcon,
       submit = submit,
       iconsVisible = iconsVisible
   )
}

@Composable
fun TodoItemInput(
   text: String,
   onTextChange: (String) -> Unit,
   icon: TodoIcon,
   onIconChange: (TodoIcon) -> Unit,
   submit: () -> Unit,
   iconsVisible: Boolean
) {
   Column {
       Row(
           Modifier
               .padding(horizontal = 16.dp)
               .padding(top = 16.dp)
       ) {
           TodoInputText(
               text,
               onTextChange,
               Modifier
                   .weight(1f)
                   .padding(end = 8.dp),
               submit
           )
           TodoEditButton(
               onClick = submit,
               text = "Add",
               modifier = Modifier.align(Alignment.CenterVertically),
               enabled = text.isNotBlank()
           )
       }
       if (iconsVisible) {
           AnimatedIconRow(icon, onIconChange, Modifier.padding(top = 8.dp))
       } else {
           Spacer(modifier = Modifier.height(16.dp))
       }
   }
}

在使用 Compose 时,务必要理解这种转换。我们使用了有状态可组合项 TodoItemInput,并将其拆分为两个可组合项。一个有状态 (TodoItemEntryInput),另一个无状态 (TodoItemInput)。

无状态可组合项包含所有与界面相关的代码,而有状态可组合项没有任何与界面相关的代码。这样一来,在需要以不同方式支持状态时,我们就可以重用界面代码。

再次运行应用

请再次运行应用,以确认该待办事项输入仍然有效。

恭喜,您已成功从有状态可组合项中提取了无状态可组合项,未更改其 API。

在下一部分中,我们将探讨它如何使我们可以在不同位置重用界面逻辑,而无需耦合界面与状态。

在审核设计师的新现代互动式模拟时,我们需要添加一些表示当前修改项的状态。

用于修改模式的模拟

修改模式会重用与输入模式相同的界面,但会将编辑器嵌入到列表中。

现在,我们需要确定在哪里为该编辑器添加状态。我们可以构建另一个有状态项可组合项“TodoRowOrInlineEditor”,用于处理一个项的显示或修改操作,但一次只能显示一个编辑器。仔细查看设计会发现,在修改模式下顶部同样会发生变化。因此,我们必须执行状态提升,以便共享状态。

TodoActivity 的状态树

cb2036bb21fbb712.png

由于 TodoItemEntryInputTodoInlineEditor 都需要了解当前的编辑器状态才能在界面顶部隐藏输入,我们需要至少将状态提升到 TodoScreen界面是层次结构中最低级别的可组合项,也是需要知道修改操作的每个可组合项的通用父级。

不过,由于编辑器派生自列表并将对其进行转变,因此它应实际位于列表的旁边。我们希望将状态提升到可以修改的级别。该列表位于 TodoViewModel 中,我们恰好要在该位置添加列表。

转换 TodoViewModel 以使用 mutableStateListOf

在此部分中,您将在 TodoViewModel 中添加编辑器的状态;在下一部分,您将使用它来构建内嵌编辑器。

同时,我们将探讨如何在 ViewModel 中使用 mutableStateListOf,并了解在以 Compose 为目标时,与 LiveData<List> 相比它是如何简化状态代码的。

mutableStateListOf 让我们可以创建可观察的 MutableList 实例。这意味着,我们可以像使用 MutableList 一样使用 todoItems,这样可以消除使用 LiveData<List> 所产生的开销。

请打开 TodoViewModel.kt 并将现有 todoItems 替换为 mutableStateListOf

TodoViewModel.kt

import androidx.compose.runtime.mutableStateListOf

class TodoViewModel : ViewModel() {

   // remove the LiveData and replace it with a mutableStateListOf
   //private var _todoItems = MutableLiveData(listOf<TodoItem>())
   //val todoItems: LiveData<List<TodoItem>> = _todoItems

   // state: todoItems
   var todoItems = mutableStateListOf<TodoItem>()
    private set

   // event: addItem
   fun addItem(item: TodoItem) {
        todoItems.add(item)
   }

   // event: removeItem
   fun removeItem(item: TodoItem) {
       todoItems.remove(item)
   }
}

todoItems 的声明较短,而且捕获的行为与 LiveData 版本相同。

// state: todoItems
var todoItems = mutableStateListOf<TodoItem>()
    private set

通过指定 private set,可将对此状态对象的写入操作限制为仅可在 ViewModel 内可见的私有 setter。

更新 TodoActivityScreen 以使用新的 ViewModel

请打开 TodoActivity.kt 并更新 TodoActivityScreen,以使用新的 ViewModel

TodoActivity.kt

@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
   TodoScreen(
       items = todoViewModel.todoItems,
       onAddItem = todoViewModel::addItem,
       onRemoveItem = todoViewModel::removeItem
   )
}

再次运行应用后,您会看到它与新的 ViewModel 兼容。您已将状态更改为使用 mutableStateListOf - 现在,我们来探讨如何创建编辑器状态。

定义编辑器状态

现在可以为编辑器添加状态了。为避免待办事项文本重复,我们将直接修改列表。为此,我们将保留当前编辑器项的列表索引,而不是保留当前正在修改的文本。

请打开 TodoViewModel.kt 并添加编辑器状态。

定义一个包含当前修改位置的新 private var currentEditPosition。它将保留我们当前正在修改的列表索引。

然后,公开 currentEditItem,以使用 getter 进行组合。虽然这是一个标准的 Kotlin 函数,但 Compose 可以观察到 currentEditPosition,就像 State<TodoItem> 一样。

TodoViewModel.kt

import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue

class TodoViewModel : ViewModel() {

   // private state
   private var currentEditPosition by mutableStateOf(-1)

    // state: todoItems
    var todoItems = mutableStateListOf<TodoItem>()
        private set

   // state
   val currentEditItem: TodoItem?
       get() = todoItems.getOrNull(currentEditPosition)

   // ..

每当可组合项调用 currentEditItem 时,它都会观察 todoItemscurrentEditPosition 的变化。如果其中任何一项发生变化,该可组合项将再次调用 getter 来获取新值。

定义编辑器事件

我们定义了编辑器状态,现在需要定义可组合项可以调用来控制修改的事件。

创建三个事件:onEditItemSelected(item: TodoItem)onEditDone()onEditItemChange(item: TodoItem)

onEditItemSelectedonEditDone 事件仅会更改 currentEditPosition。更改 currentEditPosition 后,Compose 将重组所有读取 currentEditItem 的可组合项。

TodoViewModel.kt

class TodoViewModel : ViewModel() {
   ...

   // event: onEditItemSelected
   fun onEditItemSelected(item: TodoItem) {
      currentEditPosition = todoItems.indexOf(item)
   }

   // event: onEditDone
   fun onEditDone() {
      currentEditPosition = -1
   }

   // event: onEditItemChange
   fun onEditItemChange(item: TodoItem) {
      val currentItem = requireNotNull(currentEditItem)
      require(currentItem.id == item.id) {
          "You can only change an item with the same id as currentEditItem"
      }

      todoItems[currentEditPosition] = item
   }
}

事件 onEditItemChange 会在 currentEditPosition 更新列表。这会同时更改 currentEditItemtodoItems 返回的值。在执行此操作之前,需要执行一些安全检查,确保调用方没有尝试写入错误的项。

移除列表项时结束修改

请更新 removeItem 事件,以便在移除项后关闭当前编辑器。

TodoViewModel.kt

// event: removeItem
fun removeItem(item: TodoItem) {
   todoItems.remove(item)
   onEditDone() // don't keep the editor open when removing items
}

再次运行应用

大功告成!您已将 ViewModel 更新为使用 MutableState,并已了解它可如何简化可观察的状态代码。

在下一部分中,我们将为此 ViewModel 添加一项测试,然后继续构建修改界面。

由于此部分涉及的修改较多,下面列出了在应用所有更改后的 TodoViewModel 的完整列表:

TodoViewModel.kt

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel

class TodoViewModel : ViewModel() {

    private var currentEditPosition by mutableStateOf(-1)

    var todoItems = mutableStateListOf<TodoItem>()
        private set

    val currentEditItem: TodoItem?
        get() = todoItems.getOrNull(currentEditPosition)

    fun addItem(item: TodoItem) {
        todoItems.add(item)
    }

    fun removeItem(item: TodoItem) {
        todoItems.remove(item)
        onEditDone() // don't keep the editor open when removing items
    }

    fun onEditItemSelected(item: TodoItem) {
        currentEditPosition = todoItems.indexOf(item)
    }

    fun onEditDone() {
        currentEditPosition = -1
    }

    fun onEditItemChange(item: TodoItem) {
        val currentItem = requireNotNull(currentEditItem)
        require(currentItem.id == item.id) {
            "You can only change an item with the same id as currentEditItem"
        }

        todoItems[currentEditPosition] = item
    }
}

最好测试您的 ViewModel,以确保您的应用逻辑正确无误。在此部分中,我们将编写一项测试,展示如何针对状态使用 State<T> 测试视图模型。

将测试添加到 TodoViewModelTest

请打开 test/ 目录中的 TodoViewModelTest.kt,并添加用于移除项的测试:

TodoViewModelTest.kt

import com.example.statecodelab.util.generateRandomTodoItem
import com.google.common.truth.Truth.assertThat
import org.junit.Test

class TodoViewModelTest {

   @Test
   fun whenRemovingItem_updatesList() {
       // before
       val viewModel = TodoViewModel()
       val item1 = generateRandomTodoItem()
       val item2 = generateRandomTodoItem()
       viewModel.addItem(item1)
       viewModel.addItem(item2)

       // during
       viewModel.removeItem(item1)

       // after
       assertThat(viewModel.todoItems).isEqualTo(listOf(item2))
   }
}

此测试展示了如何测试直接由事件修改的 State<T>。在前面的部分中,它创建了一个新的 ViewModel,然后将两个项添加到 todoItems

我们要测试的方法是 removeItem,该方法移除了列表中的第一项。

最后,我们要使用 Truth 断言来声明该列表仅包含第二项。

如果更新是由测试直接引发的(就像我们现在通过调用 removeItem 执行的操作一样),那么就无需执行额外的操作来在测试中读取 todoItems - 它只是一个 List<TodoItem>

ViewModel 的其余部分测试遵循相同的基本模式,因此在本 Codelab 中,我们将跳过这些练习。您可以添加更多 ViewModel 测试以确认其是否正常运行,也可在已完成的模块中打开 TodoViewModelTest 以查看更多测试。

在下一部分中,我们将向界面添加新的修改模式!

我们终于准备好实现新现代互动式设计了!谨此提醒,我们一直致力于构建以下内容:

用于修改模式的模拟

修改模式会重用与输入模式相同的界面,但会将编辑器嵌入到列表中。

将状态和事件传递到 TodoScreen

我们刚刚在 TodoViewModel 中定义了此界面所需的所有状态和事件。现在,我们将更新 TodoScreen 以获取显示界面所需的状态和事件。

请打开 TodoScreen.kt 并更改 TodoScreen 的签名,以添加以下内容:

  • 当前正在修改的项目:currentlyEditing: TodoItem?
  • 三个新事件:

onStartEdit: (TodoItem) -> UnitonEditItemChange: (TodoItem) -> UnitonEditDone: () -> Unit

TodoScreen.kt

@Composable
fun TodoScreen(
   items: List<TodoItem>,
   currentlyEditing: TodoItem?,
   onAddItem: (TodoItem) -> Unit,
   onRemoveItem: (TodoItem) -> Unit,
   onStartEdit: (TodoItem) -> Unit,
   onEditItemChange: (TodoItem) -> Unit,
   onEditDone: () -> Unit
) {
   // ...
}

以上就是我们刚刚在 ViewModel 上定义的新状态和事件。

然后,在 TodoActivity.kt 中,传递 TodoActivityScreen 中的新值。

TodoActivity.kt

@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
   TodoScreen(
       items = todoViewModel.todoItems,
       currentlyEditing = todoViewModel.currentEditItem,
       onAddItem = todoViewModel::addItem,
       onRemoveItem = todoViewModel::removeItem,
       onStartEdit = todoViewModel::onEditItemSelected,
       onEditItemChange = todoViewModel::onEditItemChange,
       onEditDone = todoViewModel::onEditDone
   )
}

此操作只会传递新的 TodoScreen 所需的状态和事件。

定义内嵌编辑器可组合项

TodoScreen.kt 中创建一个新的可组合项,它使用无状态可组合项 TodoItemInput 来定义内嵌编辑器。

TodoScreen.kt

@Composable
fun TodoItemInlineEditor(
   item: TodoItem,
   onEditItemChange: (TodoItem) -> Unit,
   onEditDone: () -> Unit,
   onRemoveItem: () -> Unit
) = TodoItemInput(
   text = item.task,
   onTextChange = { onEditItemChange(item.copy(task = it)) },
   icon = item.icon,
   onIconChange = { onEditItemChange(item.copy(icon = it)) },
   submit = onEditDone,
   iconsVisible = true
)

此可组合项是无状态的。它仅显示传递的 item,并使用事件请求状态更新。因为我们之前已提取无状态可组合项 TodoItemInput,所以可以轻松地在此无状态上下文中使用它。

此示例展示了无状态可组合项的可重用性。即使标头在同一界面上使用有状态 TodoItemEntryInput,我们仍然能够将状态提升为内嵌编辑器的 ViewModel 状态。

LazyColumn 中使用内嵌编辑器

TodoScreenLazyColumn 中,如果项当前正在修改,系统会显示 TodoItemInlineEditor,否则显示 TodoRow

此外,点击某项时,也可以开始修改(而不是像以前一样将其移除)。

TodoScreen.kt

// fun TodoScreen()
// ...
LazyColumn(
   modifier = Modifier.weight(1f),
   contentPadding = PaddingValues(top = 8.dp)
) {
 items(items) { todo ->
   if (currentlyEditing?.id == todo.id) {
       TodoItemInlineEditor(
           item = currentlyEditing,
           onEditItemChange = onEditItemChange,
           onEditDone = onEditDone,
           onRemoveItem = { onRemoveItem(todo) }
       )
   } else {
       TodoRow(
           todo,
           { onStartEdit(it) },
           Modifier.fillParentMaxWidth()
       )
   }
 }
}
// ...

LazyColumn 可组合项相当于 RecyclerView。它只会重组显示当前界面所需的列表项,而且当用户滚动列表时,它将丢弃离开界面的可组合项,并为滚动的元素创建新的可组合项。

试用新的交互式编辑器!

请再次运行应用。当您点击待办事项行时,界面上会打开交互式编辑器。

显示本 Codelab 中这一阶段的应用的图片

我们使用相同的无状态界面可组合项来绘制有状态标头和交互式修改体验。此外,我们并未在执行此操作时引入任何重复状态。

即将要大功告成,但“Add”按钮似乎不适用,并且我们需要更改标头。我们要在接下来的几个步骤中完成此设计。

修改时切换标头

接下来,我们将完成标头设计,然后探讨如何替换按钮,以将设计师所需的新现代互动式设计替换为表情符号按钮。

返回 TodoScreen 可组合项,使标头响应编辑器状态的变化。如果 currentlyEditingnull,则将显示 TodoItemEntryInput 并将 elevation = true 传递给 TodoItemInputBackground。如果 currentlyEditing 不为 null,请将 elevation = false 传递给 TodoItemInputBackground,并在同一背景中显示“Editing item”文本。

TodoScreen.kt

import androidx.compose.material.MaterialTheme
import androidx.compose.ui.text.style.TextAlign

@Composable
fun TodoScreen(
   items: List<TodoItem>,
   currentlyEditing: TodoItem?,
   onAddItem: (TodoItem) -> Unit,
   onRemoveItem: (TodoItem) -> Unit,
   onStartEdit: (TodoItem) -> Unit,
   onEditItemChange: (TodoItem) -> Unit,
   onEditDone: () -> Unit
) {
   Column {
       val enableTopSection = currentlyEditing == null
       TodoItemInputBackground(elevate = enableTopSection) {
           if (enableTopSection) {
               TodoItemEntryInput(onAddItem)
           } else {
               Text(
                   "Editing item",
                   style = MaterialTheme.typography.h6,
                   textAlign = TextAlign.Center,
                   modifier = Modifier
                       .align(Alignment.CenterVertically)
                       .padding(16.dp)
                       .fillMaxWidth()
               )
           }
       }
      // ..

同样,我们要在重组时更改 Compose 树。启用顶部部分后,会显示 TodoItemEntryInput,否则会显示可显示“Editing item”的 Text 可组合项。

起始代码中的 TodoItemInputBackground 会自动为大小调整以及高度变化添加动画效果,因此当您进入修改模式时,此代码会自动在不同状态之间添加动画效果。

再次运行应用

4e5e5a27e063e67a.gif

再次运行应用后,您会看到它会为修改状态和非修改状态添加动画效果。我们的设计工作已近尾声。

在下一部分中,我们将探讨如何构建表情符号按钮的代码。

显示复杂界面的无状态可组合项可能会生成很多参数。如果参数不是过多而且直接配置可组合项的话,那么就没有问题。但是,有时您需要传递参数来配置可组合项的子项。

当前显示的是工具栏中带有“添加”按钮、内嵌编辑器中带有表情符号按钮的设计

在我们的新现代互动式设计中,设计师希望我们在顶部保留“添加”按钮,但将其替换为内嵌编辑器的两个表情符号按钮。我们可以向 TodoItemInput 添加更多参数以处理这种情况,但尚不清楚 TodoItemInput 是否能做到这一点。

我们只需要让预配置的按钮部分接受可组合项即可。这样,调用方就可以根据需要配置这些按钮,而无需与 TodoItemInput 共享配置它们所需的所有状态。

这样既可以减少传递给无状态可组合项的参数数量,又可以提高它们的可重用性。

用于传递预配置部分的模式是槽位。槽位是可组合项的参数,可让调用方描述界面的某个部分。您可以在内置的可组合 API 中找到槽位的示例。最常用的一个示例是 Scaffold

Scaffold 是在 Material Design 中描述整个界面(例如 topBarbottomBar)和界面正文的可组合项。

Scaffold 公开了可以填充您想要的任何可组合项的槽位,而不是提供数百个参数来配置界面的每个部分。这不仅减少了 Scaffold 的参数数量,而且提高了可重用性。如果您想构建自定义 topBarScaffold 非常适用于显示该内容。

@Composable
fun Scaffold(
   // ..
   topBar: @Composable (() -> Unit)? = null,
   bottomBar: @Composable (() -> Unit)? = null,
   // ..
   bodyContent: @Composable (PaddingValues) -> Unit
) {

TodoItemInput 上定义槽位

打开 TodoScreen.kt 并在无状态 TodoItemInput 上定义一个名为 buttonSlot 的新 @Composable () -> Unit 参数。

TodoScreen.kt

@Composable
fun TodoItemInput(
   text: String,
   onTextChange: (String) -> Unit,
   icon: TodoIcon,
   onIconChange: (TodoIcon) -> Unit,
   submit: () -> Unit,
   iconsVisible: Boolean,
   buttonSlot: @Composable () -> Unit
) {
  // ...

调用方可以使用所需按钮填充此通用槽位。我们将使用它来指定用于标头和内嵌编辑器的不同按钮。

显示 buttonSlot 的内容

将对 TodoEditButton 的调用替换为槽位的内容。

TodoScreen.kt

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width

@Composable
fun TodoItemInput(
   text: String,
   onTextChange: (String) -> Unit,
   icon: TodoIcon,
   onIconChange: (TodoIcon) -> Unit,
   submit: () -> Unit,
   iconsVisible: Boolean,
   buttonSlot: @Composable() () -> Unit,
) {
   Column {
       Row(
           Modifier
               .padding(horizontal = 16.dp)
               .padding(top = 16.dp)
       ) {
           TodoInputText(
               text,
               onTextChange,
               Modifier
                   .weight(1f)
                   .padding(end = 8.dp),
               submit
           )

           // New code: Replace the call to TodoEditButton with the content of the slot

           Spacer(modifier = Modifier.width(8.dp))
           Box(Modifier.align(Alignment.CenterVertically)) { buttonSlot() }

           // End new code
       }
       if (iconsVisible) {
           AnimatedIconRow(icon, onIconChange, Modifier.padding(top = 8.dp))
       } else {
           Spacer(modifier = Modifier.height(16.dp))
       }
   }
}

我们可以直接调用 buttonSlot(),但需要保持 align 居中,无论调用方垂直传递给我们什么内容。为实现此目的,我们将槽放在 Box 中,它是一个基本可组合项。

更新有状态 TodoItemEntryInput 以使用槽位

现在,我们需要更新调用方,以使用 buttonSlot。首先更新 TodoItemEntryInput

TodoScreen.kt

@Composable
fun TodoItemEntryInput(onItemComplete: (TodoItem) -> Unit) {
   val (text, onTextChange) = remember { mutableStateOf("") }
   val (icon, onIconChange) = remember { mutableStateOf(TodoIcon.Default)}

   val submit = {
        if (text.isNotBlank()) {
            onItemComplete(TodoItem(text, icon))
            onTextChange("")
            onIconChange(TodoIcon.Default)
        }
   }
   TodoItemInput(
       text = text,
       onTextChange = onTextChange,
       icon = icon,
       onIconChange = onIconChange,
       submit = submit,
       iconsVisible = text.isNotBlank()
   ) {
       TodoEditButton(onClick = submit, text = "Add", enabled = text.isNotBlank())
   }
}

由于 buttonSlotTodoItemInput 的最后一个参数,因此我们可以使用尾随 lambda 语法。然后,在 lambda 中,像之前一样调用 TodoEditButton 即可。

更新 TodoItemInlineEditor 以使用槽位

如需完成重构,请将 TodoItemInlineEditor 更改为也使用槽位:

TodoScreen.kt

import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.TextButton

@Composable
fun TodoItemInlineEditor(
   item: TodoItem,
   onEditItemChange: (TodoItem) -> Unit,
   onEditDone: () -> Unit,
   onRemoveItem: () -> Unit
) = TodoItemInput(
   text = item.task,
   onTextChange = { onEditItemChange(item.copy(task = it)) },
   icon = item.icon,
   onIconChange = { onEditItemChange(item.copy(icon = it)) },
   submit = onEditDone,
   iconsVisible = true,
   buttonSlot = {
       Row {
           val shrinkButtons = Modifier.widthIn(20.dp)
           TextButton(onClick = onEditDone, modifier = shrinkButtons) {
               Text(
                   text = "\uD83D\uDCBE", // floppy disk
                   textAlign = TextAlign.End,
                   modifier = Modifier.width(30.dp)
               )
           }
           TextButton(onClick = onRemoveItem, modifier = shrinkButtons) {
               Text(
                   text = "❌",
                   textAlign = TextAlign.End,
                   modifier = Modifier.width(30.dp)
               )
           }
       }
   }
)

现在,我们传递 buttonSlot 作为已命名的参数。然后在 buttonSlot 中构建一个 Row,其中包含用于内嵌编辑器设计的两个按钮。

再次运行应用

再次运行应用,然后运行内联编辑器。

8de6c632f654509d.gif

在此部分中,我们使用槽位自定义了无状态可组合项,以便调用方控制界面的某个部分。通过使用槽位,避免了将 TodoItemInput 与未来可能添加的所有不同设计耦合的需求。

在向无状态可组合项添加参数以自定义子项时,请评估槽位是否是更出色的设计。槽位会让可组合项更具可重用性,同时使参数数量易于管理。

恭喜,您已成功完成本 Codelab,并了解了如何在 Jetpack Compose 应用中使用单向数据流构建状态!

您了解了如何使用状态和事件来提取 Compose 中的无状态可组合项,以及如何在同一界面上重用可组合项中的复杂可组合项。您还了解了如何使用 LiveData 和 MutableState 将 ViewModel 与 Compose 集成。

后续操作

请查看 Compose 衔接课程中的其他 Codelab

示例应用

  • JetNews 演示了如何使用单向数据流,以使用有状态可组合项管理使用无状态可组合项构建的界面中的状态

参考文档