当前位置: 首页 > article >正文

Jetpack Compose 学习笔记(三)—— 状态

状态相关的官方资料:状态和 Jetpack Compose。对应的课程分集为 P26 ~ P34。

状态是 Jetpack Compose 最最核心的知识,有一定的学习难度,原因在于与传统的 View 体系的状态管理有一定的差别。但如果熟悉前端的 Vue 或 React,对声明式 UI 有一定了解,接受起来会轻松一些。

本篇文章的大纲:

  1. 无状态组件
  2. 非结构化状态
  3. 单向数据流
  4. 状态管理
  5. 状态恢复

先学习没有状态的组件,了解什么是非机构化状态,再回首 View 体系讲解为什么 Compose 要使用单向数据流,最后才学习 Compose 的状态管理与状态恢复。

在学习过程中会贯穿一个互动式 TODO 列表,这是一个有状态界面,可以修改列表内容。

1、无状态组件

本节我们先做一个静态的,无状态的组件页面,效果如下:

2024-9-21.无状态组件页面

页面整体结构是一个 Column,上面是一个用于展示纵向列表的 LazyColumn,权重为 1,下面是一个按钮。我们先按照元素由小到大的顺序介绍。

首先是 LazyColumn 的列表项,它的数据源定义为 TodoItem,包含任务名与图标:

// Data.kt:
data class TodoItem(
    val task: String,
    val icon: TodoIcon = TodoIcon.Default,
    val id: UUID = UUID.randomUUID()
)

enum class TodoIcon(val imageVector: ImageVector, @StringRes val contentDescription: Int) {
    // 使用 Material Design 的图标,需要导入 material-icons-extended 依赖
    Square(Icons.Default.CropSquare, R.string.cd_expand),
    Done(Icons.Default.Check, R.string.cd_done),
    Event(Icons.Default.Event, R.string.cd_event),
    Privacy(Icons.Default.PrivacyTip, R.string.cd_privacy),
    Trash(Icons.Default.RestoreFromTrash, R.string.cd_restore);

    companion object {
        val Default = Square
    }
}

使用 Row 来实现列表项的 UI 效果:

// TodoScreen.kt:
@Composable
fun TodoRow(
    item: TodoItem,
    modifier: Modifier = Modifier
) {
    Row(
        modifier = modifier.padding(vertical = 8.dp, horizontal = 16.dp),
        // 元素去两边,中间用 Space 填充
        horizontalArrangement = Arrangement.SpaceBetween
    ) {
        Text(text = item.task)
        Icon(
            imageVector = item.icon.imageVector,
            contentDescription = stringResource(id = item.icon.contentDescription)
        )
    }
}

列表项实现后,可以实现整个页面了:

// TodoScreen.kt:
@Composable
fun TodoScreen(items: List<TodoItem>) {
    Column {
        // 上面是 Item 列表
        LazyColumn(
            // 占满底部 Button 之外的剩余空间
            modifier = Modifier.weight(1f),
            contentPadding = PaddingValues(8.dp)
        ) {
            items(items.size) {
                TodoRow(
                    item = items[it],
                    // 宽度填满约束的最大宽度
                    modifier = Modifier.fillMaxWidth()
                )
            }
        }

        // 底部是按钮
        Button(
            onClick = { },
            modifier = Modifier
                .padding(16.dp)
                .fillMaxWidth()
        ) {
            Text(text = "Add random item")
        }
    }
}

最后在 Activity 中构造一些 TodoItem 数据并显示它:

class TodoActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            JetpackComposeTheme {
                TodoActivityScreen()
            }
        }
    }

    @Composable
    private fun TodoActivityScreen() {
        val items = listOf(
            TodoItem("Learn Compose", TodoIcon.Event),
            TodoItem("Take the codelab"),
            TodoItem("Apply state", TodoIcon.Done),
            TodoItem("Build dynamic UIs", TodoIcon.Square),
        )
        TodoScreen(items)
    }
}

2、非结构化状态

2.1 什么是状态

在科学技术中,状态指物质系统所处的状况,也指各种聚集态,如物质的固、液、气等态。当系统的温度、压力、体积、物态、物质的量、各种能量等等一定时,我们就说系统处于一个状态(state)。

应用中的状态是指可以随时间变化的任何值,这是一个宽泛定义,从 Room 数据库到类的变量,全部涵盖在内。

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

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

2.2 非结构化状态

在 Android 应用程序中,状态会根据事件进行更新。而事件是从我们的应用程序外部生成的输入,例如用户点击按钮。

下图展示了 UI 更新循环:

2024-9-21.UI 更新循环

这个循环中有三个要素:

  • 事件(Event):由用户或程序的其他部分产生。比如用户点击按钮产生了 OnClick 事件
  • 更新状态(Update State):事件处理程序更改 UI 使用的状态
  • 显示状态(Display State):更新 UI 以显示新状态

比如,点击按钮会向 EditText 填充一些文字。点击按钮就产生了 OnClick 的 Event,事件处理程序接收到事件将文字填充到 EditText 中,这就更新了 EditText 的状态。最后更新 EditText 的 UI 显示,将填充了文字之后的 EditText 显示出来。

其实这个过程中还伴随着新的事件产生,当 EditText 上的文字发生变化时,会触发父类 TextView 的 TextWatcher 接口的 onTextChanged(),这就是一个新的事件,那么接收到该事件的处理器可以继续做相关的处理更新 UI 状态并显示 UI 状态。

在 Android 传统的视图体系中的时间和状态,采用的是非结构化状态。一个简单的例子,在 EditText 中输入文字然后在 TextView 中显示这些文字,使用 ViewBinding 的代码如下:

class HelloComposeStateActivity : AppCompatActivity() {

    private val binding by lazy {
        ActivityHelloComposeStateBinding.inflate(layoutInflater)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)
        binding.textInput.doAfterTextChanged {
            updateText(it.toString())
        }
    }

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

像这种就是非结构化状态,它可能会有以下问题:

2024-9-21.非结构化状态的问题

3、单向数据流

为了解决非结构化状态的问题,Android 引入了 ViewModel 与 LiveData。将状态从 Activity 移到了 ViewModel,在 ViewModel 中,状态由 LiveData 表示。

LiveData 是一种可观察的状态容器,这意味着它可让任何人观察状态的变化。然后,我们在界面中使用 observe 方法,以便在状态变化时更新界面。

实际上,在 Android 的官方资料中,已经将 DataBinding 视为声明式框架,只不过 Compose 作为更新一代的声明式 UI 框架,代码完全基于 Kotlin DSL 实现,只依赖于单一语言(DataBinding 依赖 XML 布局),避免了语言间交互带来的性能开销和安全问题。

我们使用 ViewModel 与 LiveData 改造上面的例子:

class HelloComposeStateActivity : AppCompatActivity() {

    private val helloViewModel by viewModels<HelloViewModel>()
    private val binding by lazy {
        ActivityHelloComposeStateBinding.inflate(layoutInflater)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)
        // 1.用户输入,触发 doAfterTextChanged 事件
        binding.textInput.doAfterTextChanged { editable ->
            // 2.响应事件,由 onNameChanged 更新状态(更新 _name 值)
            helloViewModel.onNameChanged(editable.toString())
        }
        // 3.name 这个状态值发生了变化,通过 observe 通知 UI 进行更新
        helloViewModel.name.observe(this) {
            binding.helloText.text = it
        }
    }
}

class HelloViewModel : ViewModel() {
    // 可变的状态是私有的
    private val _name = MutableLiveData("")

    // LiveData 的 setValue 是 protected 的,将外部可访问的 name 转成 LiveData,
    // 目的是使公开的状态只读,避免外界通过属性直接修改状态,只能通过暴露的方法修改
    val name: LiveData<String> = _name

    fun onNameChanged(name: String) {
        _name.value = name
    }
}

这样的写法采用了单向数据流,我们可以看到次 ViewModel 是如何与事件和状态配合工作的:

  • 事件:onNameChanged 当文本输入时由 UI 调用
  • 更新状态:进行 onNameChanged 处理,然后设置状态 _name
  • 显示状态:name 的观察者被调用,通知 UI 状态变化

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

2024-9-21.单向数据流

按照上述代码中注释的序号,1 和 2 就是 event 由 Activity 流向 ViewModel 的过程,3 就是 state 由 ViewModel 流向 Activity 的过程。

2024-9-21.单向数据流的优势

测试性以 ViewModel 为例,通过调用 HelloViewModel 中的 onNameChanged() 可以对状态进行测试。但是如果使用改造前的非结构化状态代码,因为 UI 状态与 View 交织在一起,就没有办法去测试状态。

4、状态管理

状态管理的内容包括:有状态组件、状态提升、组件树与重组、remember、MutableState。

4.1 Compose 状态与状态提升

我们将单向数据流应用到第 1 节无状态组件的 TodoScreen 中。

首先,构建一个产生随机 TodoItem 的生成函数:

// DataGenerators.kt:
fun generateRandomTodoItem(): TodoItem {
    val message = listOf(
        "Learn compose",
        "Learn state",
        "Build dynamic UIs",
        "Learn UniDirectional Data Flow",
        "Integrate LiveData",
        "Integrate ViewModel",
        "Remember to savedState!",
        "Build stateless composables",
        "Use state from stateless composables"
    ).random()
    val icon = TodoIcon.values().random()
    return TodoItem(message, icon)
}

然后我们创建保存状态的 TodoViewModel,狭义的理解“状态”实际上就是 UI 上要显示的数据,那么 TodoScreen 要显示的就是 TodoItem 的列表了,列表显示的 List<TodoItem> 就是 TodoViewModel 中要保存的状态:

class TodoViewModel : ViewModel() {

    // 正常来讲,MutableLiveData 内保存的应该是 MutableList<TodoItem>,这样才可以直接修改,但是课程
    // 举例使用的就是 List<TodoItem>,这样无法直接修改,只能将其内容转成 MutableList<TodoItem> 修改
    // 后再重新赋值
    private val _todoItems = MutableLiveData(listOf<TodoItem>())

    val todoItems: LiveData<List<TodoItem>> = _todoItems

    fun addItem(item: TodoItem) {
        // 课程给的是这种将 item 存入 List 然后与原始的 List 合并再赋值的方式,会产生多个集合
        // + 是进行了运算符重载,会将两个集合合并为一个新集合
        _todoItems.value = _todoItems.value!! + listOf(item)
    }

    fun removeItem(item: TodoItem) {
        // 课程给出这种先转成 MutableList 然后移除 item 再重新赋值的方式
        _todoItems.value = _todoItems.value!!.toMutableList().also {
            it.remove(item)
        }
    }
}

接下来要改造 TodoScreen 中的 UI 组件,先为 TodoRow 添加一个点击事件的回调函数:

@Composable
fun TodoRow(
    item: TodoItem,
    // 点击事件的回调函数
    onItemClicked: (TodoItem) -> Unit,
    modifier: Modifier = Modifier
) {
    Row(
        modifier = modifier
            .padding(vertical = 8.dp, horizontal = 16.dp)
            .clickable { onItemClicked(item) },
        // 元素去两边,中间用 Space 填充
        horizontalArrangement = Arrangement.SpaceBetween
    ) {
        Text(text = item.task)
        Icon(
            imageVector = item.icon.imageVector,
            contentDescription = stringResource(id = item.icon.contentDescription)
        )
    }
}

然后在 TodoScreen 中增加添加与移除 TodoItem 的回调函数,供 TodoViewModel 实现:

@Composable
fun TodoScreen(
    items: List<TodoItem>,
    // 根据单向数据流模型,这些事件要由 ViewModel 实现
    onAddItem: (TodoItem) -> Unit,
    onRemoveItem: (TodoItem) -> Unit
) {
    Column {
        LazyColumn(
            modifier = Modifier.weight(1f),
            contentPadding = PaddingValues(8.dp)
        ) {
            items(items.size) {
                TodoRow(
                    item = items[it],
                    modifier = Modifier.fillMaxWidth(),
                    // 点击 Item 进行移除
                    onItemClicked = onRemoveItem
                )
            }
        }

        Button(
            // 点击添加随机 TodoItem,通过回调函数的形式交给 TodoViewModel 处理
            onClick = { onAddItem(generateRandomTodoItem()) },
            modifier = Modifier
                .padding(16.dp)
                .fillMaxWidth()
        ) {
            Text(text = "Add random item")
        }
    }
}

最后在 TodoActivity 中通过 TodoViewModel 获取状态,并传给 TodoScreen;而 TodoScreen 上回调函数的具体实现都要经过 TodoViewModel:

	@Composable
    private fun TodoActivityScreen() {
        /*val items = listOf(
            TodoItem("Learn Compose", TodoIcon.Event),
            TodoItem("Take the codelab"),
            TodoItem("Apply state", TodoIcon.Done),
            TodoItem("Build dynamic UIs", TodoIcon.Square),
        )*/
        // 从 ViewModel 中获取被监听的状态,observeAsState 会监听 LiveData 值的变化并用 State 表示,
        // 成为 State 的好处就是状态变化时 UI 会自动更新
        val items: List<TodoItem> by todoViewModel.todoItems.observeAsState(listOf())

        // 将状态 item 由 ViewModel 流向 UI,而 UI 上产生的事件(AddItem、RemoveItem)
        // 由 UI 流向 ViewModel,从而触发 ViewModel 的事件处理逻辑
        TodoScreen(
            items = items,
            onAddItem = { todoViewModel.addItem(it) },
            onRemoveItem = { todoViewModel.removeItem(it) }
        )
    }

items 作为状态保存在 ViewModel 中,以参数形式传给 TodoScreen 这个 UI 组件,实现了状态的向下流动;而 onAddItem、onRemoveItem 这些事件,以回调函数的形式向上流动,交给 ViewModel,由 ViewModel 对事件进行处理(ViewModel 更新 LiveData 也就是更新了状态,进而触发 Composable 函数重组,实现 UI 内容的更新)。

下面开始对状态提升的论述。

如果可组合项是无状态的,那它如何才能显示可修改的列表?为实现此目的,我们使用状态提升技术。Compose 中的状态提升是一种将状态转移至可组合项的调用方以使可组合项无状态的模式。无状态组件更容易测试,往往有更少的错误,并提供更多的重用机会。

像我们这个例子中的 TodoScreen 就有很强的可重用性,并且易于测试。因为它内部只需要专心描述这个界面,而无需担心状态管理,因为状态管理的工作已经抽取到 ViewModel 中进行了。不论你这个数据是来自于网络还是数据库,不论状态的转移细节是怎样的,TodoScreen 都不用关心,因为那是 ViewModel 的任务。因此 TodoScreen 可以拿到其他项目或组件中复用,所以才说他具有复用性。

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

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

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

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

  • value: T:当前要显示的值
  • onValueChange: (T) -> Unit:请求更改值的事件,其中 T 是建议的新值

本节的例子其实就说明了如何使用 TodoViewModel 来提升 TodoScreen 中的状态。完成操作后,会创建如下所示的单向数据流设计:

2024-9-22.udf-hello-screen

本节的内容是需要仔细体会,慢慢回味的。实际上总结一下,就是可组合项只需专注于描述 UI,而状态管理交给 ViewModel 来实现。状态以参数的形式传递给可组合项,而 UI 上发生的事件则通过可组合项的参数函数(本质是一个回调函数)回调给可组合项的调用方,调用方再将事件的具体响应(一般是更新状态,即数据)交给 ViewModel 处理。ViewModel 更新状态后(这个状态是由 observeAsState 生成的),可自动刷新 UI。

4.2 重组与 remember

我们通过一个例子来引入重组的概念。

假如现在有一个新的设计,要求通过 Add 按钮添加到列表中的 TodoItem 的图标的透明度都是介于 0.3 ~ 0.9 之间的随机 alpha。

按照上述要求,先提供一种实现方案:

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

// 生成一个在 [0.3,0.9] 区间的浮点数
private fun randomTint() = Random.nextFloat().coerceIn(0.3f, 0.9f)

但是,这样做,每次列表更改时,已经存在的列表项的图标会更改颜色,这是不正确的。为此,我们要先引入组件树的概念,以下就是 Compose 为 TodoScreen 生成的组件树:

2024-9-22.TodoScreen组件树

列表项是通过 TodoScreen 上的 items 参数传入 LazyColumn,再进一步传入 TodoRow 的。因此当 items 这个状态发生变化时,LazyColumn 是会重组(也就是重绘)的,并且是局部的重组,不会将整个 TodoScreen 都刷新,只刷新 LazyColumn 不会刷新没有发生状态变化的底部按钮。

重组的含义:

在命令式界面模型中,如需更改某个组件,可以在该组件上调用 setter 以更改其内部状态。在 Compose 中,可以使用新数据再次调用可组合函数。这样做会导致函数进行重组 —— 系统会根据需要使用新数据重新绘制函数发出的组件。Compose 框架可以智能地仅重组已更改的组件

由于在重组时,Row 内的每一个 Icon 的 tint 属性都会取新的值,造成已经存在的列表项的图标透明度也会发生变化。

为了解决上述问题,我们将内存引入可组合函数:

  • remember 提供了可组合函数的内存
  • 系统会将 remember 计算的值存储在组合树中,而且只有当 remember 的键发生变化时才会重新计算该值

2024-9-22.将内存引入可组合函数

可以将 remember 看作是为函数提供单个对象的存储空间,过程与 private val 属性在对象中执行的操作相同:

2024-9-22.内存引入示意图

使用 remember,传入 TodoItem.id 作为 key 来记录每个 Item 的图标透明度,这样重组时会保持这个值:

@Composable
fun TodoRow(
    item: TodoItem,
    onItemClicked: (TodoItem) -> Unit,
    modifier: Modifier = Modifier
) {
    Row(
        modifier = modifier
            .padding(vertical = 8.dp, horizontal = 16.dp)
            .clickable { onItemClicked(item) },
        horizontalArrangement = Arrangement.SpaceBetween
    ) {
        Text(text = item.task)

        // 使用 remember 记住透明度的值,重组时会保持住这个值
        val iconAlpha: Float = remember(item.id) { randomTint() }

        Icon(
            imageVector = item.icon.imageVector,
            contentDescription = stringResource(id = item.icon.contentDescription),
            tint = LocalContentColor.current.copy(alpha = iconAlpha)
        )
    }
}

在组件内部通过 remember 定义的状态 iconAlpha 会使得 TodoRow 这个组件成为一个有状态的组件。

有状态与无状态:

  • 使用 remember 存储对象的可组合项会创建内部状态,是该可组合项有状态
  • 在调用方不需要控制状态,并且不必自行管理状态便可使用状态的情况下,“有状态”会非常有用。但是,具有内部状态的可组合项往往不易重复使用,也更难测试
  • 无状态可组合项是指不保持任何状态的可组合项。实现无状态的一种简单方法是使用状态提升

4.3 MutableState

新的需求,向列表中增加列表项时,手动输入名称并选择图标。我们先用 MutableState 实现向输入框中输入字符,然后 Add 按钮变为可用状态的效果:

2024-9-22.MutableState演示

先实现输入框与按钮的可组合项:

/**
 * 输入框组件 TextField,使用此前状态提升时给的公式,要在
 * 可组合项的参数上指定数据 T 以及回调函数 (T) -> Unit,
 * 这里对于 TextField 而言,状态的类型是 String
 */
@Composable
fun TodoInputText(
    text: String,
    onTextChange: (String) -> Unit,
    modifier: Modifier = Modifier
) {
    TextField(
        value = text,
        onValueChange = onTextChange,
        modifier = modifier,
        maxLines = 1,
        // 背景设置为透明色,textFieldColors 中可以指定很多元素的颜色,比如 cursorColor 光标颜色
        colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Transparent)
    )
}

/**
 * 按钮组合项,也是使用状态提升
 */
@Composable
fun TodoEditButton(
    onClick: () -> Unit,
    text: String,
    modifier: Modifier = Modifier,
    enabled: Boolean = true
) {
    TextButton(
        onClick = onClick,
        shape = CircleShape,
        colors = ButtonDefaults.buttonColors(),
        modifier = modifier,
        enabled = enabled,
    ) {
        Text(text = text)
    }
}

然后将两个可组合项放入一个更大的组件中:

@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
    // text 保存状态值,默认值为 mutableStateOf 内的 "",
    // setText 是用于更改状态值的函数
    val (text, setText) = remember { mutableStateOf("") }

    // 输入框与按钮组成的 Row 与底部图片是纵向排列
    Column {
        // 输入框与按钮组成一排
        Row(
            modifier = Modifier
                .padding(horizontal = 16.dp)
                .padding(top = 16.dp)
        ) {
            // 输入框
            TodoInputText(
                text = text,
                onTextChange = setText,
                modifier = Modifier
                    .weight(1f)
                    .padding(end = 8.dp)
            )

            // 按钮
            TodoEditButton(
                onClick = {
                    // 根据输入的文字创建 TodoItem 放入回调函数中
                    onItemComplete(TodoItem(text))
                    // 重置输入框,模拟点击按钮后添加项目成功清除已输入内容的情形
                    setText("")
                },
                text = "Add",
                modifier = Modifier.align(Alignment.CenterVertically),
                // 输入框内文字不为空时激活按钮
                enabled = text.isNotBlank()
            )
        }
    }
}

首先我们要理解这句话:

	// 这个函数使用 remember 给自己添加内存,然后在内存中存储一个由 mutableStateOf 创建的
    // MutableState<String>。MutableState 是 Compose 的内置类型,提供了一个可观察的状态
    // 持有者,它的一般形式为:
    // val (value, valueSetter) = remember { mutableStateOf(defaultValue) }
    // 对 value 的任何更改都将自动重新组合读取此状态的任何可组合函数
    // text 状态的类型为:val text: String
    // setText 函数类型为:val setText: (String) -> Unit
	val (text, setText) = remember { mutableStateOf("") }

注释上说了很多,实际上很简单,就是用 remember 为一个默认值为 “” 的 MutableState<String>(初始值为空字符串决定了泛型类型为 String)开辟了内存来保存它。

等号左侧是解构语法,由于 remember 会直接返回 mutableStateOf(“”) 的引用,追查 mutableStateOf() 的返回值,发现会追到 SnapshotState.kt 中的 SnapshotMutableStateImpl 类,查看它的解构声明:

internal open class SnapshotMutableStateImpl<T>(
    value: T,
    override val policy: SnapshotMutationPolicy<T>
) : StateObject, SnapshotMutableState<T> {
    override operator fun component1(): T = value

    override operator fun component2(): (T) -> Unit = { value = it }
}

刚好对应上我们说的,text 是属性值,而 setText 是修改属性值的函数。这样后续的代码就容易理解了:

  • 使用解构语法 val (text, setText) = remember { mutableStateOf("") } 声明输入框文字的状态 text 与修改该状态的函数 setText
  • 输入框 TodoInputText 的数据源是 text,而当输入文字发生变化时,通过 onTextChange 指定 setText 去修改状态,状态一变会引发重组,这样新的 text 就会显示在 TodoInputText 之上
  • TodoEditButton 也引用了 text 这个状态,目的是通过 enabled 属性判断按钮是否应该被激活。此外,在点击按钮触发 onClick 事件时,应该生成 TodoItem 传给回调函数,由调用方实现回调函数决定如何添加

添加的逻辑将在后面实现,目前就先打个 log:

	override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            JetpackComposeTheme {
                TodoItemInput { item ->
                    Log.d("Frank", "item = ${item.task}")
                }
            }
        }
    }

下面对 MutableState 做一个总结:

  • MutableState 是一种提供可观察状态容器的内置 Compose 类型
  • 对 value 进行任何更改都会自动重组用于读取此状态的所有可组合函数
  • 可以通过以下三种方式声明可组合对象:
    • val state = remember { mutableStateOf(default) }
    • val state by remember { mutableStateOf(default) }
    • val (value, setValue) = remember { mutableStateOf(default) }
  • 在组合中创建 State<T>(或其他有状态对象)时,请务必对其执行 remember 操作,否则它会在每次重组时重新初始化
  • MutableState<T> 类似于 MutableLiveData<T>,但与 Compose 运行时集成。由于它是可观察的,它会在更新时通知 Compose

状态的广义理解是一切可以变的值,狭义理解是 State对象,如 MutableState。

4.4 输入框下方图标

完成输入框下方的动画图标,这一点没有引入新的知识点,还是对 MutableState 的应用,完成后的效果如下(效果图为了让动画效果明显故意拉长了动画时间):

2024-9-23.MutableState添加动画图标后演示

下面来介绍实现过程,我们从小组件开始一点一点进行封装,首先是最小的可变图标按钮。先思考,这个根元素为 TextButton 的可组合项的参数都有哪些:

  • 既然是一个按钮,那么点击时会发生事件,用函数 onIconSelected: () -> Unit 负责将事件回调给调用方
  • 按钮根据是否选中要显示不同的颜色,因此需要传入当前按钮是否被选中 isSelected: Boolean
  • 按钮显示的图片资源以及 ContentDescription 也需要外界提供:icon: ImageVectoriconContentDescription: Int
  • 最后是修饰符 modifier: Modifier = Modifier

从布局结构上来讲,TextButton 内分为上下两部分,上面是图标,下面是被选中后要显示的下划线部分:

@Composable
fun SelectableIconButton(
    icon: ImageVector,
    iconContentDescription: Int,
    onIconSelected: () -> Unit,
    isSelected: Boolean,
    modifier: Modifier = Modifier
) {
    // 图标选中与未选中时的颜色
    val tint = if (isSelected) {
        MaterialTheme.colors.primary
    } else {
        MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
    }

    TextButton(
        onClick = onIconSelected,
        shape = CircleShape,
        modifier = modifier
    ) {
        Column {
            Icon(
                imageVector = icon,
                tint = tint,
                contentDescription = stringResource(id = iconContentDescription),
            )

            // 图标在选中时下方有一个下划线,未选中时下方也要留出相应的空间
            if (isSelected) {
                // 选中时的下划线使用 Box 设置出空间后用颜色填充
                Box(
                    modifier = Modifier
                        .padding(top = 3.dp)
                        .width(icon.defaultWidth)
                        .height(1.dp)
                        .background(tint)
                )
            } else {
                Spacer(modifier = Modifier.height(4.dp))
            }
        }
    }
}

多个 SelectableIconButton 会组成一排图标,我们封装为 IconRow。该可组合项需要的参数:

  • 向下流动的状态:被选中的 TodoIcon:icon: TodoIcon
  • 向上流动的事件:实际上是将 SelectableIconButton 回调的 onIconSelected 包装为 IconRow 合适的事件继续回调。即点击某一个按钮,对于 SelectableIconButton 而言是被选中,对于 IconRow 而言是这一排被选中的图标发生了变化,因此事件声明为 onIconChange: (TodoIcon) -> Unit

从布局上来看,就是将 TodoIcon 中定义的枚举图标取出放一排:

@Composable
fun IconRow(
    icon: TodoIcon,
    onIconChange: (TodoIcon) -> Unit,
    modifier: Modifier = Modifier
) {
    // 这一排摆放的是用 TodoIcon 内定义的枚举对象生成的可选图标按钮 SelectableIconButton
    Row(modifier) {
        for (todoIcon in TodoIcon.values()) {
            SelectableIconButton(
                icon = todoIcon.imageVector,
                iconContentDescription = todoIcon.contentDescription,
                onIconSelected = { onIconChange(todoIcon) },
                // 如果当前遍历的 todoIcon 等于被选中的 icon,那么认为该图标被选中
                isSelected = icon == todoIcon
            )
        }
    }
}

再增加动画功能,进一步封装为 AnimatedIconRow,参数包含 IconRow 的三个参数,此外还包含一个 visible: Boolean = true 表示动画是否可见:

/**
 * [icon] 表示当前选中的图标,该参数作为状态,而修改
 * 状态的函数是 [onIconChange],这两项用于状态提升
 */
@Composable
fun AnimatedIconRow(
    icon: TodoIcon,
    onIconChange: (TodoIcon) -> Unit,
    modifier: Modifier = Modifier,
    visible: Boolean = true
) {
    // 显示与隐藏图标的动画
    val enter = remember { fadeIn(animationSpec = TweenSpec(300, easing = FastOutLinearInEasing)) }
    val exit = remember { fadeOut(animationSpec = TweenSpec(100, easing = FastOutSlowInEasing)) }

    Box(modifier.defaultMinSize(minHeight = 16.dp)) {
        // 应用可见性动画在一排图标上
        AnimatedVisibility(
            visible = visible,
            enter = enter,
            exit = exit
        ) {
            IconRow(icon = icon, onIconChange = onIconChange)
        }
    }
}

最后在总的组件 TodoItemInput 中添加 AnimatedIconRow:

@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
    val (text, setText) = remember { mutableStateOf("") }

    // 图标状态
    val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default) }
    val iconsVisible = text.isNotBlank()

    // 输入框与按钮组成的 Row 与底部图片是纵向排列
    Column {
        // 输入框与按钮组成一排
        Row(
            modifier = 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()
            )
        }

        // 水平动画图标
        AnimatedIconRow(
            icon = icon,
            onIconChange = setIcon,
            modifier = Modifier.padding(top = 8.dp),
            visible = iconsVisible
        )

        if (!iconsVisible) {
            Spacer(modifier = Modifier.height(16.dp))
        }
    }
}

主要是将被选中的图标作为状态,setIcon 作为修改选中图标的函数。图标是否可见取决于 text 这个状态是否为空,所以才需要添加:

    // 图标状态
    val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default) }
    val iconsVisible = text.isNotBlank()

增加 AnimatedIconRow 时,向下流动的状态是被选中的 icon,各可组合项会根据该状态展示 UI,页面的初始状态就是根据状态的初始值 TodoIcon.Default 进行显示的;

点击按钮时,在底部的 SelectableIconButton 发生 onClick 事件, 一直向上传递到顶层的可组合项 TodoItemInput 中的 onIconChange 参数,在这里指定 setIcon 为处理该事件的函数。onIconChange 函数是带着唯一参数 TodoIcon,该参数类型是可以被推断出来的,那么可以直接使用函数引用的方式传递函数,不必显式调用(这是 Kotlin 语法糖,也是基础),因此直接传 setIcon,该函数会将参数上的 TodoIcon 赋值给对应的状态 icon。

上一步的最后,状态 icon 由于 setIcon 的设置发生变化,所以,所有读取了该状态的可组合项都要进行重组,这样就实现了 UI 的局部刷新。

经过 4.3 与 4.4 两节,你能明显发现,使用 remember + MutableState 这个组合,写的代码要比 4.1 通过 ViewModel 要更少,但是可以实现相同的效果。

4.5 配置软键盘

当前在通过软键盘输入时,右下角的确认按钮,点击后会换行:

2024-9-22.软键盘1

而我们想让它与 Add 按钮有相同的作用,即点击后添加项目。想做到这一点,需要如下两项:

  • keyboardOptions:用于启用显示完成 IME 操作
  • keyboardActions:用于指定响应触发的特定 IME 操作而触发的操作。在我们的例子中,一旦按下 Done,应该调用 submit 添加数据并隐藏键盘

更新 TodoInputText,添加软键盘相关配置:

@OptIn(ExperimentalComposeUiApi::class) // LocalSoftwareKeyboardController
@Composable
fun TodoInputText(
    text: String,
    onTextChange: (String) -> Unit,
    modifier: Modifier = Modifier,
    onImeAction: () -> Unit = {}
) {
    val keyboardController = LocalSoftwareKeyboardController.current

    TextField(
        value = text,
        onValueChange = onTextChange,
        modifier = modifier,
        maxLines = 1,
        colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Transparent),
        // 配置软键盘,将 imeAction 由默认的 ImeAction.Default 改为 ImeAction.Done
        keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
        // 设置输入结束时的行为 onDone:回调函数 onImeAction 与隐藏软键盘
        keyboardActions = KeyboardActions(onDone = {
            onImeAction()
            keyboardController?.hide()
        })
    )
}

再修改调用 TodoInputText 的可组合项 TodoItemInput 的代码,由于点击 Add 按钮与软键盘的 Done 效果一致,都是重置输入框与选择的图标模拟提交的情形,所以可以将这些操作提取到一个函数中:

@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {

    val (text, setText) = remember { mutableStateOf("") }
    val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default) }
    val iconsVisible = text.isNotBlank()

    // 输入完成后点击 Add 按钮或软键盘点击确定的动作
    val submit = {
        onItemComplete(TodoItem(text))
        setIcon(TodoIcon.Default)
        setText("")
    }

    // 输入框与按钮组成的 Row 与底部图片是纵向排列
    Column {
        // 输入框与按钮组成一排
        Row(
            modifier = Modifier
                .padding(horizontal = 16.dp)
                .padding(top = 16.dp)
        ) {
            // 输入框
            TodoInputText(
                text = text,
                onTextChange = setText,
                modifier = Modifier
                    .weight(1f)
                    .padding(end = 8.dp),
                // 指定点击软键盘的 Done 由 submit 处理
                onImeAction = submit
            )

            // 按钮
            TodoEditButton(
                // 点击事件指定由 submit 处理
                onClick = submit,
                text = "Add",
                modifier = Modifier.align(Alignment.CenterVertically),
                enabled = text.isNotBlank()
            )
        }

        AnimatedIconRow(
            icon = icon,
            onIconChange = setIcon,
            modifier = Modifier.padding(top = 8.dp),
            visible = iconsVisible
        )

        if (!iconsVisible) {
            Spacer(modifier = Modifier.height(16.dp))
        }
    }
}

效果如下:

2024-9-23.软键盘设置

可以看到此时软键盘右下角的按钮,从原来的换行符号变成了现在的对勾。

4.6 编辑模式

主要有两项工作:

  1. 将 4.5 节封装好的输入组件放到 TodoScreen 的顶部
  2. 点击列表项时,进入对该列表项的编辑状态,可以通过按钮保存或者删除该列表项,同时,屏幕顶部的输入框变为文本显示状态,显示 “Editting Item”

效果图如下:

2024-9-24.编辑模式演示

移植输入组件

首先我们移植输入组件,将其放到 TodoScreen 的顶部,为了与列表项进行区分,为其添加灰色背景:

2024-9-24.编辑模式1缩小

首先我们要做一件事,就是对 TodoItemInput 做状态提升。为什么要这么做?因为前面我们说过,状态提升可以增强组件的复用性,由于这个输入组件在编辑某一条列表项时还会出现类似的结构,所以我们决定将其内部定义的状态提取到更高层次的组件中以提升其复用性:

/**
 * 对 TodoItemInput 内的状态再次进行状态提升,拿到本可组合项中
 * 以提升 TodoItemInput 的可重用性,因为有不同的地方会多次用到
 * 该可组合项
 */
@Composable
fun TodoItemEntryInput(onItemComplete: (TodoItem) -> Unit) {
    // 文字状态
    val (text, setText) = remember { mutableStateOf("") }

    // 图标状态
    val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default) }
    val iconsVisible = text.isNotBlank()

    // 输入完成后点击 Add 按钮或软键盘点击确定的动作
    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
) {
    // 输入框与按钮组成的 Row 与底部图片是纵向排列
    Column {
        // 输入框与按钮组成一排
        Row(
            modifier = Modifier
                .padding(horizontal = 16.dp)
                .padding(top = 16.dp)
        ) {
            // 输入框
            TodoInputText(
                text = text,
                onTextChange = onTextChange,
                modifier = Modifier
                    .weight(1f)
                    .padding(end = 8.dp),
                onImeAction = submit
            )

            // 按钮
            TodoEditButton(
                onClick = submit,
                text = "Add",
                modifier = Modifier.align(Alignment.CenterVertically),
                enabled = text.isNotBlank()
            )
        }

        AnimatedIconRow(
            icon = icon,
            onIconChange = onIconChange,
            modifier = Modifier.padding(top = 8.dp),
            visible = iconsVisible
        )

        if (!iconsVisible) {
            Spacer(modifier = Modifier.height(16.dp))
        }
    }
}

将 TodoItemInput 内定义的状态抽取到 TodoItemEntryInput 中,为 TodoItemInput 增加相应的参数。

然后做一个背景是灰色的输入组件,只不过将灰色背景包含的具体组件做成插槽 API:

fun TodoItemInputBackground(
    elevate: Boolean,
    modifier: Modifier = Modifier,
    // 将具体组件做成 Slots API
    content: @Composable RowScope.() -> Unit
) {
    // 以帧动画的形式展现 Surface 底部的阴影
    val animatedElevation by animateDpAsState(if (elevate) 1.dp else 0.dp, TweenSpec(500))

    Surface(
        color = MaterialTheme.colors.onSurface.copy(alpha = 0.05f),
        shape = RectangleShape,
        // Surface 底部有一个小小的阴影
        elevation = animatedElevation
    ) {
        Row(
            modifier = modifier.animateContentSize(animationSpec = TweenSpec(300)),
            content = content
        )
    }
}

这样一来,我们就可以将 TodoItemInputBackground 添加到 TodoScreen 的顶部:

@Composable
fun TodoScreen(
    items: List<TodoItem>,
    onAddItem: (TodoItem) -> Unit,
    onRemoveItem: (TodoItem) -> Unit
) {
    Column {
        // 在顶部添加编辑名字与图标的输入框,TodoItemInputBackground 是包含输入框的灰色背景
        TodoItemInputBackground(elevate = true) {
            // 在插槽 API 中传入具体的输入框组件
            TodoItemEntryInput(onItemComplete = onAddItem)
        }

        // 中间是 Item 列表
        LazyColumn(
            // 占满底部 Button 之外的剩余空间
            modifier = Modifier.weight(1f),
            contentPadding = PaddingValues(8.dp)
        ) {
            items(items.size) {
                TodoRow(
                    item = items[it],
                    modifier = Modifier.fillMaxWidth(),
                    onItemClicked = onRemoveItem
                )
            }
        }

        // 底部是按钮
        Button(
            onClick = { onAddItem(generateRandomTodoItem()) },
            modifier = Modifier
                .padding(16.dp)
                .fillMaxWidth()
        ) {
            Text(text = "Add random item")
        }
    }
}

这样就能做到本节贴出的图片内的效果了。

最后再说一下课程给出的本节进行状态提升的理论知识:

单向数据流既适用于高级架构,也适用于使用 Jetpack Compose 的单个可组合项的设计。现在,我们希望使事件始终向上流动,而状态始终向下流动。这意味着需要让状态从 TodoItemInput 向下流动,而事件向上流动。为实现此目的,需要将状态从子可组合项 TodoInputTextField(我们代码实现为 TodoInputText)移到父级 TodoItemInput:

2024-9-23.状态上移

列表项编辑

当前我们点击列表项会直接删除该项目,我们要升级功能,变为点击后进入编辑模式,可以重命名该项目并决定保存还是删除。同时,屏幕顶部的输入框要变成状态提示文字 “Editting Item”:

2024-9-24.编辑模式缩小

我们还是从较小的元素一步一步封装到大的组件,首先是输入组件 TodoItemInput。虽然在上一小节【移植输入组件】内已经对其进行过一次状态提升,但是还不够,想要继续提升该组件的复用性,需要将文本输入框右侧的布局做成插槽 API。

解释一下这样做的原因,就是 TodoItemInput 在我们这个页面中会用到两次:

  • 非编辑状态下,页面顶部的输入框,右侧是 Add 按钮
  • 编辑状态下,被编辑的列表项,在输入框的右侧有保存和删除两个按钮

所以你能看到,两次使用的不同仅仅是输入框右侧的布局,因此我们将这个不同的部分做成可组合项的参数,即成为了插槽 API,由外界输入这个布局的具体样式。

改造后的 TodoItemInput 如下:

@Composable
fun TodoItemInput(
    text: String,
    onTextChange: (String) -> Unit,
    icon: TodoIcon,
    onIconChange: (TodoIcon) -> Unit,
    submit: () -> Unit,
    iconsVisible: Boolean,
    // 增加插槽 API 决定输入框右侧的组件样式
    buttonSlot: @Composable () -> Unit = {},
) {
    // 输入框与按钮组成的 Row 与底部图片是纵向排列
    Column {
        // 输入框与按钮组成一排
        Row(
            modifier = Modifier
                .padding(horizontal = 16.dp)
                .padding(top = 16.dp)
        ) {
            // 输入框
            TodoInputText(
                text = text,
                onTextChange = onTextChange,
                modifier = Modifier
                    .weight(1f)
                    .padding(end = 8.dp),
                onImeAction = submit
            )

            // 原来固定的按钮,现在取消掉,由 buttonSlot 代替
            /*TodoEditButton(
                onClick = submit,
                text = "Add",
                modifier = Modifier.align(Alignment.CenterVertically),
                enabled = text.isNotBlank()
            )*/

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

        AnimatedIconRow(
            icon = icon,
            onIconChange = onIconChange,
            modifier = Modifier.padding(top = 8.dp),
            visible = iconsVisible
        )

        if (!iconsVisible) {
            Spacer(modifier = Modifier.height(16.dp))
        }
    }
}

两处使用该组件的地方,先修改第一处,就是非编辑状态下,页面顶部的组件 TodoItemEntryInput:

@Composable
fun TodoItemEntryInput(onItemComplete: (TodoItem) -> Unit) {
    // 文字状态
    val (text, setText) = remember { mutableStateOf("") }

    // 图标状态
    val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default) }
    val iconsVisible = text.isNotBlank()

    // 输入完成后点击 Add 按钮或软键盘点击确定的动作
    val submit = {
        onItemComplete(TodoItem(text, icon))
        setIcon(TodoIcon.Default)
        setText("")
    }

    TodoItemInput(
        text = text,
        onTextChange = setText,
        icon = icon,
        onIconChange = setIcon,
        submit = submit,
        iconsVisible = iconsVisible
    ) {
        // 传入插槽 API 实例,Add 按钮
        TodoEditButton(
            onClick = submit,
            text = "Add",
            // 因为没有 RowScope 这样的作用域,因此无法使用 align
//            modifier = Modifier.align(Alignment.CenterVertically),
            enabled = text.isNotBlank()
        )
    }
}

这里要注意一点,就是传入 TodoEditButton 时,被注释掉的修饰符,原本想使用 Modifier.align(Alignment.CenterVertically),但是由于这个 TodoEditButton 并没有在 RowScope 内,所以它拿不到 Column.kt 下的 Modifier.align(alignment: Alignment.Vertical) 这个函数,它只能拿到 foundation 包下其他 align 函数。

你再看 TodoItemInput 内新增的 Box 就可以指定 Modifier.align(Alignment.CenterVertically),原因是 Box 所在的是 Row 这个可组合项的插槽 API 的位置,该插槽 API 是 RowScope 的扩展函数:

@Composable
inline fun Row(
    modifier: Modifier = Modifier,
    horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
    verticalAlignment: Alignment.Vertical = Alignment.Top,
    // 插槽 API 是 RowScope 的扩展
    content: @Composable RowScope.() -> Unit
)

再来看第二处使用 TodoItemInput 的地方,就是编辑状态下被点击的列表项,新增一个可组合项 TodoItemInlineEditor,还是先考虑参数:

  • 状态:要显示的数据(文字与图标)来自于 TodoItem
  • 事件:根据 UI 交互,可能发生三种事件:
    • TodoItem 的数据,包括文字和图标发生变化:onEditItemChange: (TodoItem) -> Unit
    • 点击保存按钮编辑完成:onEditDone: () -> Unit
    • 点击删除按钮删除该 TodoItem:onRemoveItem: () -> Unit`

代码如下:

/**
 * 点击列表中的 TodoItem,会弹出输入框用于编辑该 TodoItem
 * @param item: TodoItem 被选中的 TodoItem
 * @param onEditItemChange:  文本改变时的回调函数
 * @param onEditDone: 编辑完成时的回调函数
 * @param onRemoveItem: 删除 TodoItem 的回调函数
 */
@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
    ) {
        // 插槽 API 提供输入框右侧的两个按钮的布局
        Row {
            val shrinkButtons = Modifier.widthIn(20.dp)
            TextButton(onClick = onEditDone, modifier = shrinkButtons) {
                Text(
                    text = "\uD83D\uDCBE", // Emoji 符号:软盘
                    textAlign = TextAlign.End
                )
            }

            TextButton(onClick = onRemoveItem, modifier = shrinkButtons) {
                Text(
                    text = "✘", // Emoji 符号:软盘
                    textAlign = TextAlign.End
                )
            }
        }
    }
}

组件准备好后,需要放到上一级的 TodoScreen 中进行显示。由于子可组合项增加了编辑模式的功能,因此 TodoScreen 的参数以及内容也要做相应的修改。

参数需要增加:

  • 状态(向下流动至组件):TodoScreen 需要知道当前哪个 TodoItem 处于编辑模式以展示不同的组件内容:currentlyEditing: TodoItem?
  • 事件(向上流动至 ViewModel):开始编辑、TodoItem 内容发生改变、编辑结束

示例代码如下:

@Composable
fun TodoScreen(
    items: List<TodoItem>,
    // 当前正在编辑的 TodoItem
    currentlyEditing: TodoItem?,
    onAddItem: (TodoItem) -> Unit,
    onRemoveItem: (TodoItem) -> Unit,
    // 开始编辑
    onStartEdit: (TodoItem) -> Unit,
    // TodoItem 内容发生改变
    onEditItemChange: (TodoItem) -> Unit,
    // 编辑结束
    onEditDone: () -> Unit
) {
    Column {
        // 顶部输入框在没有编辑任何一个列表项时才显示,否则显示 "Editing Item" 文本
        val enableTopSection = currentlyEditing == null

        // 在顶部添加编辑名字与图标的输入框
        TodoItemInputBackground(elevate = true) {
            if (enableTopSection) {
                TodoItemEntryInput(onItemComplete = onAddItem)
            } else {
                // 处于编辑状态时,界面顶部显示 "Editing Item" 文字
                Text(
                    text = "Editing Item",
                    style = MaterialTheme.typography.h6,
                    textAlign = TextAlign.Center,
                    modifier = Modifier
                        .align(Alignment.CenterVertically)
                        .padding(16.dp)
                        .fillMaxWidth()
                )
            }
        }

        // 中间是 Item 列表
        LazyColumn(
            // 占满底部 Button 之外的剩余空间
            modifier = Modifier.weight(1f),
            contentPadding = PaddingValues(8.dp)
        ) {
            items(items.size) {
                // 如果当前构建的 Item 是正在编辑的,则显示编辑的输入框
                if (currentlyEditing?.id == items[it].id) {
                    TodoItemInlineEditor(
                        item = currentlyEditing,
                        onEditItemChange = onEditItemChange,
                        onEditDone = onEditDone,
                        onRemoveItem = { onRemoveItem(items[it]) }
                    )
                } else {
                    TodoRow(
                        item = items[it],
                        // 宽度填满约束的最大宽度
                        modifier = Modifier.fillMaxWidth(),
                        // 点击 Item 进行移除
                        onItemClicked = { todoItem -> onStartEdit(todoItem) }
                    )
                }
            }
        }

        // 底部是按钮
        Button(
            // 点击添加随机 TodoItem,通过回调函数的形式交给 TodoViewModel 处理
            onClick = { onAddItem(generateRandomTodoItem()) },
            modifier = Modifier
                .padding(16.dp)
                .fillMaxWidth()
        ) {
            Text(text = "Add random item")
        }
    }
}

再向上一级,就是要将修改后的 TodoScreen 显示到 Activity 中,修改后的内容如下:

class TodoActivity : ComponentActivity() {

    private val todoViewModel by viewModels<TodoViewModel>()
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            JetpackComposeTheme {
                TodoActivityScreen()
            }
        }
    }

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

与 Activity 交互的 ViewModel 为了处理 TodoScreen 的事件(回调函数),修改为如下内容:

class TodoViewModel : ViewModel() {

    // 只读,由于 ViewModel 在屏幕旋转之后数据仍会保存,所以不需要用 remember,这样
    // ViewModel 就成为了一个状态容器
    var todoItems = mutableStateListOf<TodoItem>()
        private set

    // 当前正在编辑的 TodoItem 的索引
    private var currentEditPosition by mutableStateOf(-1)

    // 当前正在编辑的 TodoItem 对象,因为 currentEditPosition 初始值为 -1,
    // 所以我们不使用 [] 去获取元素,而是使用 getOrNull,在索引超限时会返回 null
    val currentEditItem: TodoItem?
        get() = todoItems.getOrNull(currentEditPosition)/*todoItems[currentEditPosition]*/

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

    fun removeItem(item: TodoItem) {
        todoItems.remove(item)
        // 删除元素后直接结束编辑状态
        onEditDone()
    }

    fun onEditDone() {
        currentEditPosition = -1
    }

    // 当 TodoItem 列表中的条目被选中时,传入该对象,获取它在列表中的索引位置
    fun onEditItemSelected(item: TodoItem) {
        currentEditPosition = todoItems.indexOf(item)
    }

    // TodoItem 编辑完成,重新给集合中的 TodoItem 赋值
    // id 属性值不能修改,需要进行校验
    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
    }
}

最重要的修改就是将 todoItems 从原来的 MutableLiveData 保存一个 List<TodoItem> 替换为 StateList mutableStateListOf<TodoItem>(),该函数实际上是生成了一个 SnapshotStateList,而 SnapshotStateList 则实现了 MutableList<T>StateObject 两个接口,是一个有状态的可变 List。

至此,Todo 页面就算完成了。

5、状态恢复

在重新创建 Activity 或进程后,我们可以使用 rememberSaveable 恢复界面状态。rememberSaveable 可以在重组后保持状态。此外,rememberSaveable 也可以在重新创建 Activity 和进程后保持状态。

注意,上面说的重新创建进程不是说按了返回键或者直接在应用管理器中杀掉进程后,该状态还能保持。而是指通过 Home 键切换到桌面,可能运行其他应用程序一段时间后,再回到本应用中,状态还可保持。即冷启动无法保持状态,温启动可以。

添加到 Bundle 的所有数据类型都会自动保存。如果要保存无法添加到 Bundle 的内容,有以下几种方式:

  • Parcelize:最简单的方案是向对象添加 @Parcelize 注解,对象将变为可打包状态并且可以捆绑
  • MapSaver:如果某种原因导致 @Parcelize 不合适,可以使用 MapSaver 定义自己的规则,规定如何将对象转换为系统可保存到 Bundle 的一组值
  • ListSaver:为了避免需要为映射定义键,也可以使用 listSaver 并将其索引用作键

使用 Parcelize 需要添加 Kotlin 对 Parcelize 的插件,版本与 Kotlin 对 Android 的插件版本保持一致即可:

// 项目的 build.gradle
plugins {
    id 'com.android.application' version '8.0.0' apply false
    id 'com.android.library' version '8.0.0' apply false
    id 'org.jetbrains.kotlin.android' version '1.7.20' apply false
    // 增加 parcelize 插件
    id 'org.jetbrains.kotlin.plugin.parcelize' version '1.7.20' apply false
}

然后引入该插件:

// 模块的 build.gradle
plugins {
    id 'com.android.application'
    id 'org.jetbrains.kotlin.android'
    id 'org.jetbrains.kotlin.plugin.parcelize'
}

创建一个示例 Activity,初始状态显示文字"Spain-Madrid",点击按钮后显示"China-Shanghai":

2024-9-25.状态恢复演示

并且这个 “China-Shanghai” 在屏幕旋转后仍然存在,示例代码:

class StateRecoveryParcelableActivity : ComponentActivity() {

    // 添加 @Parcelize 会自动实现 Parcelable
    @Parcelize
    data class City(val name: String, val country: String) : Parcelable

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            JetpackComposeTheme {
                CityScreen()
            }
        }
    }

    @Composable
    fun CityScreen() {
        val (city, setCity) = rememberSaveable {
            mutableStateOf(City("Madrid", "Spain"))
        }

        Row(
            verticalAlignment = Alignment.CenterVertically,
            modifier = Modifier.padding(10.dp)
        ) {
            TextButton(
                onClick = { setCity(City("Shanghai", "China")) },
                colors = ButtonDefaults.buttonColors()
            ) {
                Text(text = "Click to change.")
            }

            Text(text = "${city.country}-${city.name}")
        }
    }
}

实现状态保存的关键是两点:

  1. 为数据类 City 添加了 @Parcelize 注解,编译时会自动生成该类实现 Parcelable 接口的代码
  2. 使用 rememberSaveable 保存 city 这个状态,假如这里只是使用了 remember,那么屏幕旋转后,状态会丢失,显示为初始值"Spain-Madrid"

再来看第二种方式,使用 MapSaver:

class StateRecoveryMapSaverActivity : ComponentActivity() {

    data class City(val name: String, val country: String)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            JetpackComposeTheme {
                CityScreen()
            }
        }
    }

    @Composable
    fun CityScreen() {
        // 指定 MapSaver,用于指导存储时如何构建一个 map 对象,获取时如何构建一个 City 对象
        val citySaver = run {
            val nameKey = "Name"
            val countryKey = "Country"
            mapSaver(
                save = { mapOf(nameKey to it.name, countryKey to it.country) },
                restore = { City(it[nameKey] as String, it[countryKey] as String) }
            )
        }

        val (city, setCity) = rememberSaveable(stateSaver = citySaver) {
            mutableStateOf(City("Madrid", "Spain"))
        }

        Row(
            verticalAlignment = Alignment.CenterVertically,
            modifier = Modifier.padding(10.dp)
        ) {
            TextButton(
                onClick = { setCity(City("Shanghai", "China")) },
                colors = ButtonDefaults.buttonColors()
            ) {
                Text(text = "Click to change.")
            }

            Text(text = "${city.country}-${city.name}")
        }
    }
}

这种方式不需要数据类实现 Parcelable 接口,你只需要在调用 rememberSaveable 的时候指定 stateSaver 这个参数即可:

@Composable
fun <T> rememberSaveable(
    vararg inputs: Any?,
    stateSaver: Saver<T, out Any>,
    key: String? = null,
    init: () -> MutableState<T>
): MutableState<T> = rememberSaveable(
    *inputs,
    saver = mutableStateSaver(stateSaver),
    key = key,
    init = init
)

Saver 是一个接口,定义了两个方法用来描述如何保存与恢复对象:

interface Saver<Original, Saveable : Any> {
    /**
     * Convert the value into a saveable one. If null is returned the value will not be saved.
     */
    fun SaverScope.save(value: Original): Saveable?

    /**
     * Convert the restored value back to the original Class. If null is returned the value will
     * not be restored and would be initialized again instead.
     */
    fun restore(value: Saveable): Original?
}

而示例代码通过 run 将匿名的 mapSaver 实现后把该值作为返回值给到 citySaver 传给 rememberSaveable 就实现了相同的效果。

第三种方法使用 ListSaver 也是大同小异的方式,相比于第二种方法,只需修改 citySaver 的构建方式:

    @Composable
    fun CityScreen() {
        // 指定 ListSaver,与 MapList 不同的就是使用 List 按序保存/恢复数据
        val citySaver = listSaver<City, Any>(
            save = { listOf(it.name, it.country) },
            restore = { City(it[0] as String, it[1] as String) }
        )
        ...
    }

http://www.kler.cn/a/467745.html

相关文章:

  • 遗传学的“正反”之道:探寻生命密码的两把钥匙
  • 微信小程序Uniapp
  • 【数学建模笔记】评价模型-基于熵权法的TOPSIS模型
  • OpenCV的TickMeter计时类
  • kernel32.dll动态链接库报错要怎解决?详细解析kernel32.dll文件缺失解决方案
  • GESP真题 | 2024年12月1级-编程题4《美丽数字》及答案(C++版)
  • 第一节:电路连接【51单片机+A4988+步进电机教程】
  • C++11编译器优化以及引用折叠
  • 加密算法分类与介绍:保障信息安全的核心技术
  • 【Leetcode】731. 我的日程安排表 II
  • 大麦抢票科技狠活
  • 【WPF】 数据绑定机制之INotifyPropertyChanged
  • 【华为OD-E卷 - 网上商城优惠活动 100分(python、java、c++、js、c)】
  • Huawei LiteOS 开发指南
  • AWS 申请证书、配置load balancer、配置域名
  • springboot3 redis 批量删除特定的 key 或带有特定前缀的 key
  • 我用AI学Android Jetpack Compose之入门篇(2)
  • 044_小驰私房菜_MTK平台Camera关闭多帧
  • 金融租赁系统的创新与发展推动行业效率提升
  • 使用python调用翻译大模型实现本地翻译【exe客户端版】
  • c#2025/1/4 周六
  • HTML5 手风琴(Accordion)详解
  • 基于单片机的俄罗斯方块设计
  • badboy坏男孩批量抓取录制接口(接口可导入到jmeter中使用)
  • node.js之---事件循环机制
  • 力扣【SQL连续问题】