在 Jetpack Compose 中使用状态

1. 简介

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

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

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

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

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

b5c4dc05d1e54d5a.png

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

学习内容

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

所需条件

构建内容

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

2. 准备工作

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

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

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

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

b059413b0cf9113a.png

在 Android Studio 中打开项目

  1. 在“Welcome to Android Studio”窗口中,选择 c01826594f360d94.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 实现。

3. 了解单向数据流

界面更新循环

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

是什么原因导致状态更新?在简介中,我们谈到状态是随着时间变化的任何值。对于 Android 应用中的状态而言,这只是冰山一角。

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

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

f415ca9336d83142.png

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

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

非结构化状态

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

879ed27ccab2eed3.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。

8a331b9c1b392bef.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) 更新,所以不太可能由于界面变复杂而出现状态更新不完整的 bug
  • 界面一致性:通过使用可观察的状态容器,所有状态更新都会立即反映在界面中

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

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

4. Compose 和 ViewModel

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

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

7998ef0a441d4b3.png

探索 TodoScreen 可组合项

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

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

TodoScreen.kt

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

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

4cedcddc3df7c5d6.png

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

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

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

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

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

  • 事件 - 当用户请求添加或移除事项时,TodoScreen 会调用 onAddItemonRemoveItem
  • 更新状态 - TodoScreen 的调用方可以通过更新状态来响应这些事件
  • 显示状态 - 状态更新后,系统将使用新的 items 再次调用 TodoScreen,而且 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 中的状态。完成操作后,会创建如下所示的单向数据流设计:

f555d7b9be40144c.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

a195c5b4d2a5ea0f.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>

再次运行应用

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

7998ef0a441d4b3.png

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

在下一部分中,我们将探讨如何向可组合函数添加记忆功能。

5. Compose 中的记忆功能

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

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

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

在此部分中,我们将探讨如何向可组合函数添加记忆功能。该记忆功能是我们在下一部分中向 Compose 添加状态所需的构建块。

散乱设计

设计师提供的模拟

40a46273d161497a.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)
       )
   }
}

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

cdb483885e713651.png

探索重组

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

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

2e53e9411aeee11e.gif

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

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

Compose 会生成一个树,但与您可能熟悉的 Android View 系统中的界面树略有不同。Compose 不会生成界面 widget 的树,而是生成可组合项的树。我们可以直观呈现 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 树

该图显示了在 Compose 树中作为 TodoRow 的新子项显示的 iconAlpha。

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

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

remember(todo.id) { randomTint() }

remember 调用包含两部分:

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

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

可组合项的重组必须具有幂等性。使用 remember 将对 randomTint 的调用括起来,便可在重组后跳过对随机值的调用,除非待办事项发生变化。因此,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 在这里的用法还有一个非常细微的 bug。如果您反复点击“添加随机待办事项”,然后滚动,以尝试添加足够多的待办事项行,让一些行滚动超出界面范围。滚动时,您会发现图标每次滚动回到界面时,Alpha 值都会发生变化。

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

6. Compose 中的状态

在上一部分中,我们了解了如何使可组合函数具有记忆功能,现在我们将探索使用该记忆功能向可组合项添加状态。

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

待办事项输入(状态:已收起) 6f46071227df3625.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 的当前值。我们需要向 TodoItemInput 公开 text 状态,同时使用单向数据流。

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

也就是说,我们想让状态从 TodoItemInput 向下流动,而让事件向上流动。

TodoItemInput 的单向数据流示意图

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

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

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

e2ccddf8af39d228.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 参数。value 参数为 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 构建为有状态可组合项。

再次运行

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

767719165c35039e.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()
           )
       }
   }
}

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

7. 基于状态的动态界面

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

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

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

待办事项输入(状态:已收起 - 空白文本) 6f46071227df3625.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

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

更新事件以使用图标

请修改 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 时,我们也会将其重置为默认值。

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

3d8320f055510332.gif

使用 imeAction 完成设计

当您向设计师展示该应用时,他们会告诉您,应用应通过键盘的 IME 操作提交待办事项,就是通过右下角的蓝色按钮来实现:

附带 ImeAction.Done 的 Android 键盘

6ee2444445ec12be.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 - 用于显示“Done”IME 操作
  • keyboardActions - 用于指定在响应特定 IME 操作时触发的操作 - 在本例中,当用户按下“Done”后,我们希望调用 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
    )
}

再次运行应用来试用新图标

再次运行应用后,您会看到图标会随着文本状态的变化自动显示或隐藏。您还可以更改图标选择。点击“Add”按钮时,您会看到系统基于输入的值生成一个新的 TodoItem。

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

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

8. 提取无状态可组合项

您的设计师目前采用的是一种新的设计趋势。本周的设计摒弃了散乱界面和 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。

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

9. 在 ViewModel 中使用状态

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

修改模式的模拟

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

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

TodoActivity 的状态树

d32f2646a3f5ce65.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 函数,但 currentEditPosition 可以像 State<TodoItem> 一样在 Compose 中观察到。

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
    }
}

10. 在 ViewModel 中测试状态

最好测试一下您的 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 以查看更多测试。

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

11. 重复使用无状态可组合项

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

修改模式的模拟

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

将状态和事件传递到 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 可组合项相当于 Compose 中的 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 会自动为大小调整以及高度变化添加动画效果,因此当您进入修改模式后,此代码会自动在不同状态之间添加动画效果。

再次运行应用

99c4d82c8df52606.gif

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

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

12. 使用插槽传递界面的各个部分

显示复杂界面的无状态可组合项最终可能会包含许多参数。如果参数不是太多,直接配置可组合项的话,问题不大。但是,有时您需要传递参数来配置可组合项的子项。

显示了在工具栏中带有“Add”按钮以及在内嵌编辑器中带有表情符号按钮的设计

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

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

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

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

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

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

@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,其中包含用于内嵌编辑器设计的两个按钮。

再次运行应用

再次运行应用,然后运行内嵌编辑器!

ae3f79834a615ed0.gif

在此部分中,我们使用插槽自定义了无状态可组合项,以便调用方控制界面的某个部分。通过使用插槽,我们可以防止 TodoItemInput 与未来可能添加的任何其他设计进行耦合。

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

13. 恭喜

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

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

后续操作

查看 Compose 开发者在线课程中的其他 Codelab

示例应用

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

参考文档