Jeptpack Compose 官网教程学习笔记(三)状态

状态

Jetpack Compose中状态可以是随时间变化的任何值,可以是从数据库中的值到类的变量

Android 应用中的一些状态示例:

  • 根据网络情况显示的信息提示控件
  • 文章和相关评论
  • 点击按钮时播放的涟漪动画
  • 倒计时显示控件

主要学习内容

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

单向数据流

界面更新循环

在Android应用中状态会随着事件进行更新事件是从应用外部生成的输入,如:用户点击按钮

事件用于通知程序的某些部分有情况发生

界面更新循环
  • Event:由用户程序的其他部分生成
  • Update State:事件处理通常会更改界面所使用的状态
  • Display State:界面会更新以显示新状态

当界面更新显示新状态时会等待直至下一个Event的输入或直接触发一个新Event,由此进行循环

非结构化状态

在介绍 Compose 之前,我们先来了解一下 Android View 系统中的事件和状态

Android View 系统中的事件和状态

2834915029)]

要实现这种效果很显然是通过监听TextField的输入事件更新Text即可,当前项目采用ViewBinding,要采用ViewBinding需要在build.gradle文件进行配置

android {
    ...
    buildFeatures {
        compose true
        viewBinding true
    }
    ...
}
...

代码实现

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center_vertical"
    tools:context=".HelloActivity"
    android:orientation="vertical">

    <TextView
        android:id="@+id/helloText"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>

    <EditText
        android:id="@+id/textInput"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>

</LinearLayout>
class HelloActivity : AppCompatActivity() {
    private val binding by lazy {
        ActivityHelloBinding.inflate(layoutInflater)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)

        binding.textInput.doAfterTextChanged { str ->
            updateText(str?.toString())
        }
    }

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

这种事件处理方式可以说是非常常见,是典型的非结构化状态。非结构化状态需要我们手动在所有涉及状态【此处为textInput中的值】改变的位置去调用Update State操作

对于像这样的小示例来说,没有问题。但是,随着界面的扩大,越来越难管理。

当我们继续添加更多的事件和状态时,可能会出现几个问题:

  • 测试 - 由于UI的状态是与 Views 代码交织在一起的,因此很难测试此代码
  • 部分状态更新 - 当界面中有更多事件时,很容易忘记更新部分状态以响应事件。会导致用户看到不一致或不正确的UI
  • 部分界面更新 - 由于状态每次发生变化后,都需要我们手动更新UI,因此有时很容易忘记,更新的界面中可能会显示过时的数据
  • 代码复杂性 - 如果以这种模式进行编码,则很难提取某些逻辑。因此,代码往往难以阅读和理解

单向数据流

为了解决这种非结构化状态导致的问题,我们可以引入了 ViewModelLiveData

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

class HelloActivity : AppCompatActivity() {
    private val binding by lazy {
        ActivityHelloBinding.inflate(layoutInflater)
    }

    private val viewModel by viewModels<HelloViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)

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

        binding.textInput.doAfterTextChanged { str->
            viewModel.updateName(str.toString())
        }
    }
}

class HelloViewModel : ViewModel() {
    private val _name = MutableLiveData("")
    val name: LiveData<String> = _name

    fun updateName(str: String) {
        _name.value = str
    }
}
private val _name = MutableLiveData("")
val name: LiveData<String> = _name

在此示例中,我们将状态从 Activity 移到了 ViewModel。在 ViewModel 中,状态由 LiveData 表示

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

可能对这里会有些疑惑,为什么教程中会这样去写?

通过查看源码可以知道LiveData对于修改函数 (postValuesetValue) 的访问修饰符为protected,这意味着LiveData对于非子类的修改是关闭的,这样的写法可以防止数据在ViewModel外被修改,只有通过调用ViewModel内提供的函数才能修改

public class MutableLiveData<T> extends LiveData<T> {
...
@Override
public void postValue(T value) {
  super.postValue(value);
}

@Override
public void setValue(T value) {
  super.setValue(value);
}
}

public abstract class LiveData<T> {
...
protected void postValue(T value) {
  boolean postTask;
  synchronized (mDataLock) {
      postTask = mPendingData == NOT_SET;
      mPendingData = value;
  }
  if (!postTask) {
      return;
  }
  ArchTaskExecutor.getInstance().postToMainThread(mPostValueRunnable);
}

protected void setValue(T value) {
  assertMainThread("setValue");
  mVersion++;
  mData = value;
  dispatchingValue(null);
}
...
}

ViewModel 是如何与事件和状态配合工作的

  • Event:当TextInput文本输入更改时由UI调用
  • Update State:在事件回调中通过updateName设置状态_name
  • Display Statename的观察者被调用,通知UI状态变化
单向数据流

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

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

  • 可测试性 - 通过将状态与显示状态的界面分开,您可以更轻松地测试 ViewModel 和 activity
  • 状态封装 - 因为状态只能在一个位置 (ViewModel) 更新,所以不容易出现部分状态更新错误
  • 界面一致性:所有状态更新都通过观察可观察状态立即反映在UI中

单向数据流是一种事件向上流动而状态向下流动的设计

例如,在 ViewModel 中,系统会使用方法调用从界面向上传递事件,而使用 LiveData 向下流动状态

准备工作

在状态的学习中我们将逐步完成一个有状态界面,其中会显示可修改的互动式 TODO 列表


state_movie.gif

可能是Example版本更新了,和官网教程上对应不上

不过在Github中可以找到有对应的样例,示例下载

当然你也可以跟着我的思路来一步一步实现,代码上会与示例大同小异
首先先引入所需依赖,在Compse项目默认依赖的基础上新增两个依赖

dependencies {
    ...
    implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.4.1"
    implementation "androidx.compose.material:material-icons-extended:$compose_version"
    implementation "androidx.compose.runtime:runtime-livedata:$compose_version"
}

创建数据类 - TodoItem

data class TodoItem(
    val task: String,
    val icon: TodoIcon = TodoIcon.values().random(),
    val id: UUID = UUID.randomUUID()
)
//使用枚举类型限定使用图标类型
enum class TodoIcon(
    val imageVector: ImageVector,
    //官方示例使用 @StringRes 引用,为了方便这里直接使用String
    val contentDescriptor: String
) {
    Square(Icons.Default.CropSquare, "Crop"),
    Done(Icons.Default.Done, "Done"),
    Event(Icons.Default.Event, "Event"),
    Privacy(Icons.Default.PrivacyTip, "Privacy"),
    Trash(Icons.Default.RestoreFromTrash, "Restore");

    companion object {
        val Default = Square
    }
}

//生成测试用数据
fun generateRandomTodoItemList(size:Int): List<TodoItem> {
    return List(size){
        generateRandomTodoItem()
    }
}

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

然后编写可组合函数

@Composable
fun TodoScreen(
    modifier: Modifier = Modifier,
    items: List<TodoItem>
) {
    Column(modifier = modifier) {
        LazyColumn(modifier = Modifier.weight(1f)) {
            items(items.size) { index ->
                TodoListItem(item = items[index])
            }
        }
    }
}

@Composable
fun TodoListItem(modifier: Modifier = Modifier, item: TodoItem) {
    Row(modifier = modifier.padding(4.dp)) {
        Text(
            text = item.task, modifier = Modifier
                .weight(1f)
                .align(CenterVertically)
        )
        Icon(
            imageVector = item.icon.imageVector,
            contentDescription = item.icon.contentDescriptor
        )
    }
}
列表截图

Compose与ViewModel

状态提升

之前我们使用了LiveDataViewModel在Android View系统中实现单向数据流,那么在Compose中如何使用 ViewModel 在 Compose 中使用单向数据流

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

构建界面

通过底部的按钮实现添加一个随机列表项,点击列表项则删除改列表项

显然我们需要对点击事件进行处理,所以在TodoScreen中添加两个参数【点击事件回调函数】

@Composable
fun TodoScreen(
    modifier: Modifier = Modifier,
    items: List<TodoItem>,
    onAddItem: (TodoItem) -> Unit,
    onRemoveItem: (TodoItem) -> Unit
) {
    Column(modifier = modifier) {
        LazyColumn(modifier = Modifier.weight(1f)) {
            items(items.size) { index ->
                TodoListItem(modifier = Modifier.clickable {
                    onRemoveItem(items[index])
                    Log.e("TodoScreen", "do onRemoveItem" )
                }, item = items[index])
            }
        }
        Button(modifier=Modifier.fillMaxWidth(),onClick = {
            onAddItem(generateRandomTodoItem())
            Log.e("TodoScreen", "do onAddItem" )
        }) {
            Text(text = "添加一个随机列表项")
        }
    }
}
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            ActivityScreen(generateRandomTodoItemList(4).toMutableList())
        }
    }
    
    @Composable
    fun ActivityScreen(list: MutableList<TodoItem>) {
        TodoScreen(items = list, onAddItem = { item ->
            list.add(item)
        }, onRemoveItem = { item ->
            list.remove(item)
        })
    }
}

但是运行之后会发现列表并不会添加或删除,但是可以看到有打印信息【回调函数被调用】

实际上,可组合项TodoScreen无状态的。它只会显示传入的事项列表,而且无法直接修改该列表。而是通过传递两个请求更改的事件:onRemoveItemonAddItem对列表进行修改

无状态可组合项是指无法直接更改任何状态的可组合项。无状态可组合项更容易测试、往往没有多少 bug,可重性高

如果可组合项是无状态的,那它如何才能显示可修改的列表?

为实现此目的,它会使用一种称为状态提升的技术。状态提升是一种将状态上移以使组件变为无状态的模式

即将组件的状态管理交给调用者,由调用者决定是否进行状态的修改。使得TodoScreen 与状态的管理方式是完全解耦的

此案例的界面更新循环:

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

状态提升是一种将状态上移以使组件变为无状态的模式

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

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

定义ViewModel

class TodoViewModel : ViewModel() {
    private val _todoList = MutableLiveData<List<TodoItem>>(listOf())
    val todoList = _todoList

    fun addItem(item: TodoItem) {
        _todoList.value = _todoList.value!! + item
    }

    fun removeItem(item: TodoItem) {
        _todoList.value = _todoList.value!!.toMutableList().also {
            it.remove(item)
        }
    }
}

TodoViewModel中定义一个状态变量和两个事件。我们使用此 ViewModel 来提升 TodoScreen 中的状态,会创建如下所示的单向数据流

单向数据流

kotlin中!!操作符可以将可空类型强制转换为不可空类型,当转换对象为null时会报错

MutableLiveData中要通知观察者数据变化需要监听对象发生变化,若是单纯地新增或删除列表中的值,列表本身的值不会变化。所以需要列表对象发生变化才会通知

plustoMutableList都会新建一个对象

public operator fun <T> Collection<T>.plus(element: T): List<T> {
    val result = ArrayList<T>(size + 1)
    result.addAll(this)
    result.add(element)
    return result
}

public fun <T> Collection<T>.toMutableList(): MutableList<T> {
    return ArrayList(this)
}
向上流动事件

ActivityScreen可组合函数中,向ViewModel传递addItemremoveItem

@Composable
fun ActivityScreen(viewModel: TodoViewModel) {
    val list:List<TodoItem> by viewModel.todoList.observeAsState(listOf())

    TodoScreen(
        items = list!!,
        onAddItem = { item ->
            viewModel.addItem(item)
        },
        onRemoveItem = viewModel::removeItem
    )
}

TodoScreen调用onAddItemonRemoveItem时,可以将调用传递到ViewModel对应的事件

onRemoveItem = viewModel::removeItem,方法引用语法

向下传递状态
val list:List<TodoItem> by viewModel.todoList.observeAsState(listOf())

通过这行代码观察LiveData,并能让我们直接将当前值用作List<TodoItem>

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

observeAsState可观察 LiveData 并返回State对象,每当 LiveData 发生变化时,该对象都会更新

其实observeAsState将观察和创建状态合在一起了而已,效果上与下面的代码一致

@Composable
fun ActivityScreen(viewModel: TodoViewModel) {
    var list by remember {
        //创建State
        mutableStateOf(viewModel.todoList.value)
    }

    viewModel.todoList.observe(this) {
        //当todoList中值发生变化时,修改State中的值通知Compose进行重组
        list = it
    }

    TodoScreen(
        items = list!!,
        onAddItem = { item ->
            viewModel.addItem(item)
        },
        onRemoveItem = viewModel::removeItem
    )
}

Compose中的记忆功能

既然有无状态可组合项,那么自然就有有状态可组合项

有状态可组合项是一种具有可以随时间变化的状态的可组合项

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

我们使得TodoListItem中的图标都使用介于 0.3 到 0.9 之间的随机 Alpha 值调节色调

@Composable
fun TodoListItem(modifier: Modifier = Modifier, item: TodoItem) {
    Row(modifier = modifier.padding(4.dp)) {
        Text(
            text = item.task, 
            modifier = Modifier
                .weight(1f)
                .align(CenterVertically)
        )
        val tintColor = item.icon.imageVector.tintColor
        Icon(
            imageVector = item.icon.imageVector,
            contentDescription = item.icon.contentDescriptor,
            tint = tintColor.copy(alpha = randomTintAlpha())
        )
    }
}

fun randomTintAlpha() = Random.nextFloat().coerceIn(.3f, .9f)

然而当我们运行应用时,在添加列表项或删除列表项时会发现列表项中的图标颜色在发生变化

重组

每当列表发生变化时,对应状态变化通知 Compose 开始重组重组是使用新的输入重新调用可组合项以更新 Compose 树的过程,也就是在重新调用TodoListItem中又一次调用了randomTint导致alpha发生变化

重组过程中会再次运行相同的可组合项

Compose 会生成一个可组合项的树。我们可以直观呈现 TodoScreen,如下所示:

TodoScreenTree.png

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

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

附带效应是指在可组合函数范围之外发生的任何变化

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

remember

我们不希望每次 TodoListItem 重组时色调都发生变化。为此,我们需要有一个变量来记住我们在上一次组合中使用的alpha

Compose 使我们可以将值存储在组合树中,因此我们可以在 TodoListItem中将 iconAlpha 存储在组合树中

remember为可组合函数提供了记忆功能。

系统会将由 remember 计算的值存储在组合树中,而且只有当 remember 的键发生变化时才会重新计算该值。

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

@Composable
fun TodoListItem(modifier: Modifier = Modifier, item: TodoItem) {
    Row(modifier = modifier.padding(4.dp)) {
        ...
        val tintColor = item.icon.imageVector.tintColor
        val tintAlpha = remember(item.id) {
            randomTintAlpha()
        }
        Icon(
            imageVector = item.icon.imageVector,
            contentDescription = item.icon.contentDescriptor,
            tint = tintColor.copy(alpha = tintAlpha)
        )
    }
}

此时在Compose树中,会将tintAlpha添加进入树中

TodoScreenWithRemember.png

此时再次运行应用,添加或删除列表项时,图标颜色不会发生变化。在重组时,系统会返回 remember 存储的先前的值

此处 remember 调用包含两部分:

@Composable
inline fun <T> remember(
    key1: Any?,
    calculation: @DisallowComposableCalls () -> T
): T
  • key 参数:这次 remember 调用使用的“key”,即在圆括号中传递的那部分内容。在此示例中,我们传递 todo.id 作为 key
  • calculation 参数:一个 lambda,用于计算要记住的新值,传入尾随 lambda。在此示例中,我们使用 randomTint() 计算一个随机值

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

一旦从树中移除发出调用的可组合项,系统会立即忘记组合中记住的值

幂等性可组合项始终会对相同的输入生成相同的结果,并且不会对重组产生任何附带效应

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

提高重用性

@Composable
fun TodoListItem(
    modifier: Modifier = Modifier,
    item: TodoItem,
    tintAlpha: Float = remember(item.id,::randomTintAlpha)
) {
    Row(modifier = modifier.padding(4.dp)) {
        Text(
            text = item.task,
            modifier = Modifier
                .weight(1f)
                .align(CenterVertically)
        )
        val tintColor = item.icon.imageVector.tintColor
        Icon(
            imageVector = item.icon.imageVector,
            contentDescription = item.icon.contentDescriptor,
            tint = tintColor.copy(alpha = tintAlpha)
        )
    }
}

通过参数的方式设置tintAlpha值,可以让调用方控制此值

向可组合项添加记忆功能时,需要考虑:“某些调用方有理由想要控制此值吗?”

如果答案是肯定的,请改为构造参数。

如果答案是否定的,请将其保留为局部变量。

因为一旦从树中移除发出调用的可组合项,系统会立即忘记组合中记住的值,所以您不应依赖 remember 将重要内容存储在用于添加和移除子项的可组合项中,比如:LazyColumn

LazyColumn中列表项足够多到滚动屏幕,当一些列表项离开界面范围后,在滚动会去时图标的Alpha值会发生变化。此时需要使用 rememberSaveable保存数据

组件中添加状态

接下来通过可组合函数的记忆功能在可组合函数中添加状态,使其成为有状态组合项

  • 待办事项输入(状态:展开)
状态:展开
  • 待办事项输入(状态:收起)
状态:收起

根据文本内容进行状态的切换,当文本内容不为空则显示展开状态,反之为收起状态

在界面中修改文本是有状态的。用户每次输入字符时(甚至在更改所选内容时),当前显示的文本都会更新。在 Android View 系统中,此状态是 EditText 的内置状态,并通过 onTextChanged 监听器公开

但由于 Compose 是专为单向数据流设计的,因此它并不适用上述的写法

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

TextField若没有在输入内容事件中更新状态,那么文本框就不会更新。也就是说没有设置状态TextField中输入文字不会有任何效果

内置的可组合项专为单向数据流而设计

大多数内置可组合项为每个 API 提供至少一个无状态版本。与 View 系统相比,内置可组合项提供的是不包含有状态界面内置状态的选项,例如可修改的文本。这有助于避免应用与组件之间出现重复状态

创建有状态的TextField可组合项

@Composable
fun TodoItemInput(modifier: Modifier = Modifier) {
    Column(modifier) {
        Row(
            modifier = Modifier
                .padding(top = 12.dp)
                .padding(horizontal = 12.dp)
        ) {
            TodoInputTextField(
                modifier = Modifier
                    .weight(1f)
                    .padding(end = 6.dp)
            )
            TodoEditButton(modifier = Modifier.align(CenterVertically))
        }
    }
}

@Composable
fun TodoInputTextField(modifier: Modifier = Modifier) {
    val (text, setText) = remember { mutableStateOf("") }
    TextField(
        value = text,
        onValueChange = setText,
        maxLines = 1,
        colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Transparent),
        modifier = modifier
    )
}

@Composable
fun TodoEditButton(modifier: Modifier = Modifier) {
    Button(
        modifier = modifier,
        onClick = {},
        shape = CircleShape
    ) {
        Text(text = "Add")
    }
}

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

mutableStateOf 会创建 MutableState<T>,后者是 Compose 中内置的可观察状态容器

interface MutableState<T> : State<T> {
    override var value: T
    operator fun component1(): T
    operator fun component2(): (T) -> Unit
}

value 做出的任何更改都会自动重组用于读取此状态的所有可组合函数

可以通过3种方式声明可组合项中的MutableState对象:

//获取到MutableState<T>对象
val state = remember { mutableStateOf(default) }
//委托机制,此时value为T对象
var value by remember { mutableStateOf(default) }
//解构,可以理解为(getter,setter)
val (value, setValue) = remember { mutableStateOf(default) }

在组合中创建 State<T>(或其他有状态对象)时,请一定要对其执行 remember 操作。否则,它会在每次组合时重新初始化

按钮点击事件

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

但是我们在TodoInputTextField中存储text状态,TodoEditButton需要访问text的当前值才能进行添加。于是我们要进行状态提升,将状态从子级可组合项 TodoInputTextField 移到父级可组合项 TodoItemInput

单向数据流既适用于高级架构,也适用于使用 Jetpack Compose 的单个可组合项的设计

此时我们的做法相当于让状态从 TodoItemInput 向下流动,而让事件向上流动。状态提升是在 Compose 中构建单向数据流设计的主要模式

根据之前对于状态提升的理解,我们可以将可组合项的内置状态 T 重构为 (value: T, onValueChange: (T) -> Unit) 参数

状态提升后代码

@Composable
fun TodoInputTextField(modifier: Modifier = Modifier, text: String, onTextChange: (String) -> Unit) {
    TextField(
        value = text,
        onValueChange = onTextChange,
        maxLines = 1,
        colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Transparent),
        modifier = modifier
    )
}

@Composable
fun TodoEditButton(modifier: Modifier = Modifier, onClick: () -> Unit, enable: Boolean) {
    Button(
        modifier = modifier,
        onClick = onClick,
        enabled = enable,
        shape = CircleShape
    ) {
        Text(text = "Add")
    }
}

@Composable
fun TodoItemInput(modifier: Modifier = Modifier, onItemComplete: (TodoItem) -> Unit) {
    val (text, setText) = remember { mutableStateOf("") }
    Column(modifier) {
        Row(
            modifier = Modifier
                .padding(top = 12.dp)
                .padding(horizontal = 12.dp)
        ) {
            TodoInputTextField(
                modifier = Modifier
                    .weight(1f)
                    .padding(end = 6.dp),
                text = text,
                onTextChange = setText
            )
            TodoEditButton(
                modifier = Modifier.align(CenterVertically),
                //回调事件
                onClick = {
                    onItemComplete(TodoItem(text))
                    setText("")
                },
                //仅当文本不为空时才启用 (enable) 按钮
                enable = text.isNotBlank()
            )
        }
    }
}

以这种方式提升的状态具有以下几个重要属性:

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

基于状态的动态界面

接下来,我们将探讨如何基于状态构建动态界面

要实现内容如下:

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

已展开 - 非空白文本

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

已收起 - 空白文本

首先先构建显示的控件

@Composable
fun IconRow(
    modifier: Modifier = Modifier,
    selectIcon: TodoIcon,
    setIconChange: (TodoIcon) -> Unit
) {
    Row(
        modifier = modifier,
        horizontalArrangement = Arrangement.SpaceAround
    ) {
        TodoIcon.values().forEach { todoIcon ->
            SelectableIconButton(
                icon = todoIcon.imageVector,
                contentDescription = todoIcon.contentDescriptor,
                isSelected = selectIcon == todoIcon,
                onSelected = { setIconChange(todoIcon) }
            )
        }
    }
}

@Composable
fun SelectableIconButton(
    modifier: Modifier = Modifier,
    icon: ImageVector,
    contentDescription: String,
    isSelected: Boolean,
    onSelected:()->Unit
) {
    val tintColor = if (isSelected) {
        MaterialTheme.colors.primary
    } else {
        MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
    }

    Column(modifier = modifier) {
        TextButton(onClick = onSelected, shape = CircleShape) {
            Icon(imageVector = icon, contentDescription = contentDescription, tint = tintColor)
        }
        Divider(
            modifier = Modifier
                .width(icon.defaultWidth)
                .align(CenterHorizontally)
                .padding(top = 3.dp)
                .height(1.dp),
            color = if(isSelected)tintColor else Color.Transparent
        )
    }
}

从状态中派生iconsVisible

对于该控件我们需要在TodoItemInput新添加状态

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

很显然IconRow中需要一个状态对控件进行更新操作,但是在添加操作时也需要获取selectIcon的状态,所以需要进行状态提升。在TodoItemInput中添加了第二个状态 icon,用于存放当前选定的图标

iconsVisible 不会向 TodoItemInput 添加新状态。TodoItemInput 无法直接对其进行更改。相反,它完全基于 text 的值

我们已经知道了重组会重新调用可组合函数,所以iconsVisible会基于 text 的值进行赋值

当然也可以通过添加另一种状态,以控制图标何时可见,但仔细查看,便会发现可见性完全基于输入的文本,在构建一个状态就有些多余了

根据 iconsVisible 的值显示 IconRow。如果 iconsVisible 的值为 true,则显示 IconRow;如果为 false,则显示一个 16.dp 的分隔符

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

    Column(modifier) {
        Row(
            ...
        ) {
            TodoInputTextField(
                ...
            )
            TodoEditButton(
                onClick = {
                    //TodoItem的构造函数中添加icon
                    onItemComplete(TodoItem(text,icon))
                    setText("")
                },
                ...
            )
        }
        
        if (iconsVisible) {
            IconRow(selectIcon = icon,
                setIconChange = {
                    setIcon(it)
                })
        } else {
            Spacer(modifier = Modifier.height(4.dp))
        }
    }
}

我们基于 iconsVisible 的值动态更改组合树,这种条件式显示逻辑等同于 Android View 系统中的可见性

组合树

Compose 中没有“visibility”属性

由于 Compose 可以动态更改组合,因此您无需设置可见性,只需从组合中移除可组合项即可

官网案例中使用带有动画效果的AnimatedIconRow,动画会在之后的笔记中记录,所以此处不进行解析,如果想要使用AnimatedIconRow其代码如下:

@OptIn(ExperimentalAnimationApi::class)
@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(selectIcon = icon, setIconChange = onIconChange)
        }
    }
}

虚拟键盘确认 - imeAction

在进行输入时,Android会弹出虚拟键盘然用户进行输入,按照用户操作惯性来说:应用应该能通过键盘的 IME 操作提交添加事件,就是点击右下角的确认/换行按钮

虚拟键盘

Compose 中可以通过TextFieldkeyboardActions 中响应对应的键盘事件,所以我们需要为TodoInputTextField添加新参数onImeAction: () -> Unit响应键盘事件

@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun TodoInputTextField(
    modifier: Modifier = Modifier,
    text: String,
    onTextChange: (String) -> Unit,
    onImeAction: () -> Unit = {}
) {
    //获取当前KeyboardController
    val keyboardController = LocalSoftwareKeyboardController.current
    TextField(
        value = text,
        onValueChange = onTextChange,
        maxLines = 1,
        colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Transparent),
        modifier = modifier,
        //指定响应的事件类型
        keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
        //事件触发执行的操作
        keyboardActions = KeyboardActions(onDone = {
            onImeAction()
            //隐藏键盘
            keyboardController?.hide()
        }),
    )
}

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

  • keyboardOptions - 用于设置支持监听的IME 操作
  • keyboardActions - 用于指定在响应特定 IME 操作时触发的操作 - 在本例中,当用户按下“Done”后,我们希望调用 submit 并隐藏键盘

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

onImeAction 的行为与 TodoEditButton 完全相同。我们可以复制此代码,但这样很难对其进行长期维护,因为代码是复制的,若出现bug需要修改多个地方很容易遗漏

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

TodoItemInput代码

@Composable
fun TodoItemInput(modifier: Modifier = Modifier, 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(modifier) {
        Row(
            ...
        ) {
            TodoInputTextField(
                modifier = Modifier
                    .weight(1f)
                    .padding(end = 6.dp),
                text = text,
                onTextChange = setText,
                onImeAction = submit
            )
            TodoEditButton(
                modifier = Modifier.align(CenterVertically),
                onClick = submit,
                enable = text.isNotBlank()
            )
        }
        ...
    }
}

提取无状态可组合项

修改模式

接下来我们将构建如下所示的界面:

构建界面

点击列表项,列表项会展开,其中会重复使用与TodoItemInput的部分界面,不过将按钮更改为"保存"符号和"删除"符号

虽然部分界面相同,但是,其中的状态是完全不相同

  • TodoItemInput中状态为添加的TodoItem的值

  • 列表项中的状态为对应的TodoItem的值

所以我们需要从 TodoItemInput 提升状态。我们可以改为将可组合项拆分为两个,一个是有状态的,另一个是无状态的

我们将 TodoItemInput 拆分为两个可组合项,然后将有状态可组合项重命名为 TodoItemEntryInput,因为它只用于添加新输入的 TodoItems

从有状态可组合项中提取无状态可组合项更便于在不同位置重复使用界面

可以在 Android Studio 中使用 Refactor->Function (Extract Method) 命令执行此重构,而无需输入任何代码

  1. 选择 TodoItemInput 的界面部分(Column 及其子项)
image-20220515113028352.png
  1. 选择“Refactor”->“Function”(快捷键:Cmd/Ctl+Alt+M
image-20220515113238630.png
  1. 该新函数命名为 TodoItemInput
image-20220515113516225.png
  1. 将参数 setTextsetIcon 分别重命名为 onTextChangeonIconChange
image-20220515113752400.png
  1. 点击ok
    1. 在新的函数调用上按 Alt + Enter,然后选择 Add names to call arguments
    2. 重命名有状态函数为 TodoItemEntryInput
@Composable
fun TodoItemEntryInput(modifier: Modifier = Modifier, 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(
        modifier = modifier,
        text = text,
        onTextChange = setText,
        submit = submit,
        iconsVisible = iconsVisible,
        icon = icon,
        onIconChange = setIcon
    )
}

@Composable
fun TodoItemInput(
    modifier: Modifier,
    text: String,
    onTextChange: (String) -> Unit,
    submit: () -> Unit,
    iconsVisible: Boolean,
    icon: TodoIcon,
    onIconChange: (TodoIcon) -> Unit
) {
    Column(modifier) {
        Row(
            modifier = Modifier
                .padding(top = 12.dp)
                .padding(horizontal = 12.dp)
        ) {
            TodoInputTextField(
                modifier = Modifier
                    .weight(1f)
                    .padding(end = 6.dp),
                text = text,
                onTextChange = onTextChange,
                onImeAction = submit
            )
            TodoEditButton(
                modifier = Modifier.align(CenterVertically),
                onClick = submit,
                enable = text.isNotBlank()
            )
        }
        if (iconsVisible) {
            IconRow(selectIcon = icon,
                setIconChange = {
                    onIconChange(it)
                })
        } else {
            Spacer(modifier = Modifier.height(4.dp))
        }
    }
}

我们使用了有状态可组合项 TodoItemInput,并将其拆分为两个可组合项:一个有状态 (TodoItemEntryInput),另一个无状态 (TodoItemInput)

无状态可组合项包含所有与界面相关的代码,有状态可组合项则不包含任何与界面相关的代码。这样一来,在需要以不同的状态更新界面时,我们就可以重复使用界面代码了

ViewModel中使用状态

现在,我们需要确定在哪里添加该编辑器的状态。我们可以构建另一个有状态可组合项“TodoRowOrInlineEditor”,用于处理一个列表项的显示或修改操作,但一次只能显示一个编辑器

TodoActivityTree.png

由于 TodoItemEntryInputTodoInlineEditor 都需要了解当前的编辑器状态,TodoItemEntryInput需要决定是否隐藏,TodoInlineEditor 需要知道哪一个列表项在被编辑

我们需要将状态至少提升到 TodoScreen。界面是层次结构中最低级别的可组合项,也是需要知道修改操作的每个可组合项的通用父级

不过,由于TodoLInlineEditor派生自列表并将对其进行转变,因此它应实际位于列表的旁边。我们希望将状态提升到可以修改的级别,即 TodoViewModel

提升状态规则:

  • 状态应至少提升到使用(或读取)该状态的所有可组合项的最低共同父项
  • 状态应至少提升到它可以被更改(修改)的最高级别
  • 如果两种状态发生变化以响应相同的事件,它们应一起提升

您可以将状态提升到高于这些规则要求的级别,但如果状态提升级别过低,会使得单向数据流变得困难,甚至于不可能完成

使用mutableStateListOf

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

class TodoViewModel : ViewModel() {
    var todoList = mutableStateListOf<TodoItem>()
        private set

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

    fun removeItem(item: TodoItem) {
        todoList.remove(item)
    }
}

todoItems 的声明较短,而且捕获的行为与 LiveData 版本时效果一致

通过指定 private set,可将对此状态对象的写入操作限制在 ViewModel 内提供的函数中

使用 mutableStateListOfMutableState 完成的工作适用于 Compose

如果 View 系统也使用该 ViewModel,那么最好继续使用 LiveData

创建MutableListViewModel的使用

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            val list = viewModel.todoList

            TodoScreen(
                items = list,
                onAddItem = { item ->
                    viewModel.addItem(item)
                },
                onRemoveItem = viewModel::removeItem
            )
        }
    }

定义编辑器状态

我们在TodoViewModel中记录下当前编辑的列表项的索引

class TodoViewModel : ViewModel() {
    var todoList = mutableStateListOf<TodoItem>()
        private set
    
    //当前正在修改的列表索引
    private var currentEditPosition by mutableStateOf(-1)

    //正在修改的列表
    val currentEditItem: TodoItem?
        get() = todoList.getOrNull(currentEditPosition)

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

    fun removeItem(item: TodoItem) {
        todoList.remove(item)
    }

    ...
}

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

Compose 将观察可组合项读取的任何 State<T>,即使读取发生在由可组合项调用的标准 Kotlin 函数中,也是如此。现在,我们将从 currentEditPositiontodoItems 读取,以生成 currentEditItem。Compose会读取 currentEditItem只要出现任何变化就会发生重组

为了使 State<T> 转换发挥作用,必须State<T> 对象中读取状态

如果您已将 currentEditPosition 为-1,Compose 将无法观察到对currentEditItem所做的更改

定义编辑器事件

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

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

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

class TodoViewModel : ViewModel() {   
    fun removeItem(item: TodoItem) {
        todoList.remove(item)
        onEditDone()
    }
    
    ...
    
    fun onEditItemSelected(item: TodoItem) {
        currentEditPosition = todoList.indexOf(item)
    }

    fun onEditItemChange(item: TodoItem) {
        require(currentEditItem?.id == item.id) {
            "只能修改同一个id的item的值"
        }
        todoList[currentEditPosition] = item
    }

    fun onEditDone() {
        currentEditPosition = -1
    }
}

编写TodoInlineEditor

所有的状态和事件都准备的差不多,我们开始准备写TodoInlineEditor可组合项

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

TodoScreen中使用

@Composable
fun TodoScreen(
    modifier: Modifier = Modifier,
    items: List<TodoItem>,
    onAddItem: (TodoItem) -> Unit,
    onRemoveItem: (TodoItem) -> Unit,
    currentItem: TodoItem?,
    onEditItemChanged: (TodoItem) -> Unit,
    onEditItemSelected: (TodoItem) -> Unit,
    onEditDone: () -> Unit
) {
    Column(modifier = modifier) {
        //修改时替换顶部输入框
        if(currentItem==null){
            TodoItemEntryInput(onItemComplete = onAddItem)
        }else{
            Text(
                text = "Editing",
                textAlign = TextAlign.Center,
                style = MaterialTheme.typography.h5,
                modifier = Modifier
                    .fillMaxWidth()
                    .background(MaterialTheme.colors.onSurface.copy(alpha = 0.05f))
                    .padding(6.dp)
            )
        }

        LazyColumn(modifier = Modifier.weight(1f)) {
            items(items.size) { index ->
                val item = items[index]
                //根据id确认是否被选中
                if (item.id == currentItem?.id) {
                    TodoInlineEditor(
                        item = currentItem,
                        onEditItemChanged = onEditItemChanged,
                        onRemoveItem = onRemoveItem,
                        onEditDone = onEditDone
                    )
                } else {
                    TodoListItem(modifier = Modifier.clickable {
                        onEditItemSelected(item)
                    }, item = item)
                }

            }
        }
    }
}

@Composable
fun ActivityScreen(viewModel: TodoViewModel) {
    val list = viewModel.todoList

    TodoScreen(
        items = list,
        onAddItem = { item ->
            viewModel.addItem(item)
        },
        onRemoveItem = viewModel::removeItem,
        currentItem = viewModel.currentEditItem,
        onEditItemChanged = viewModel::onEditItemChange,
        onEditItemSelected = viewModel::onEditItemSelected,
        onEditDone = viewModel::onEditDone
    )
}

好,现在我们之差最后在列表项中将添加按钮替换为"保存"和"删除"就完成了

官网案例中使用了动画效果使得替换更为平滑,其代码如下

@Composable
fun TodoItemInputBackground(
    elevate: Boolean,
    modifier: Modifier = Modifier,
    content: @Composable RowScope.() -> Unit
) {
    val animatedElevation by animateDpAsState(if (elevate) 1.dp else 0.dp, TweenSpec(500))
    Surface(
        color = MaterialTheme.colors.onSurface.copy(alpha = 0.05f),
        elevation = animatedElevation,
        shape = RectangleShape,
    ) {
        Row(
            modifier = modifier.animateContentSize(animationSpec = TweenSpec(300)),
            content = content
        )
    }
}

@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()
               )
           }
       }
      // ..
   }
}

使用插槽传递界面

image.png

TodoItemEntryInputTodoInlineEditor只有在按钮部分有差异,我们可以使用传递参数的方式来配置TodoItemInput的按钮部分

用于传递预配置部分的模式是插槽。插槽是可组合项的参数,可让调用方描述界面的某个部分

可以在内置的可组合 API 中找到插槽的示例。最常用的一个示例为 Scaffold

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

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

插槽是可组合函数的参数,可让调用方描述界面的某个部分

请使用 @Composable () -> Unit 类型的参数声明插槽

在无状态 TodoItemInput 上定义一个名为 buttonSlot 的新 @Composable RowScope.() -> Unit 参数,同时将对 TodoEditButton 的调用替换为插槽的内容

@Composable
fun TodoItemInput(
    modifier: Modifier = Modifier,
    text: String,
    onTextChange: (String) -> Unit,
    submit: () -> Unit,
    iconsVisible: Boolean,
    icon: TodoIcon,
    onIconChange: (TodoIcon) -> Unit,
    //插槽
    buttonSlot:@Composable RowScope.()->Unit
) {
    Column(modifier) {
        Row(
            modifier = Modifier
                .padding(top = 12.dp)
                .padding(horizontal = 12.dp)
        ) {
            TodoInputTextField(
                modifier = Modifier
                    .weight(1f)
                    .padding(end = 6.dp),
                text = text,
                onTextChange = onTextChange,
                onImeAction = submit
            )
            //替换
            buttonSlot()
        }
        if (iconsVisible) {
            IconRow(selectIcon = icon,
                setIconChange = {
                    onIconChange(it)
                })
        } else {
            Spacer(modifier = Modifier.height(4.dp))
        }
    }
}

通过将插槽函数指定为@Composable RowScope.() -> Unit类,可以让调用方自己决定在布局的位置

TodoItemEntryInput插槽

更新 TodoItemEntryInput

@Composable
fun TodoItemEntryInput(modifier: Modifier = Modifier, 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(
        modifier = modifier,
        text = text,
        onTextChange = setText,
        submit = submit,
        iconsVisible = iconsVisible,
        icon = icon,
        onIconChange = setIcon,
        //插槽
        buttonSlot = {
            TodoEditButton(
                modifier = Modifier.align(CenterVertically),
                onClick = submit,
                enable = text.isNotBlank()
            )
        }
    )
}

TodoInlineEditor插槽

更新 TodoInlineEditor

@Composable
fun TodoInlineEditor(
    item: TodoItem,
    onEditItemChanged: (TodoItem) -> Unit,
    onRemoveItem: (TodoItem) -> Unit,
    onEditDone: () -> Unit,
) {
    TodoItemInput(
        text = item.task,
        onTextChange = {
            onEditItemChanged(item.copy(task = it))
        },
        submit = onEditDone,
        iconsVisible = true,
        icon = item.icon,
        onIconChange = {
            onEditItemChanged(item.copy(icon = it))
        },
        //插槽
        buttonSlot = {
            val shrinkModifier = Modifier.widthIn(20.dp)
            val textWidthModifier = Modifier.width(30.dp)
            Row (modifier = Modifier.align(CenterVertically)){
                //保存按钮
                TextButton(
                    onClick = onEditDone,
                    modifier = shrinkModifier
                ) {
                    Text(
                        text = "\uD83D\uDCBE",
                        textAlign = TextAlign.Center,
                        modifier = textWidthModifier
                    )
                }
                Spacer(modifier = Modifier.width(4.dp))
                //删除按钮
                TextButton(
                    onClick = { onRemoveItem(item) },
                    modifier = shrinkModifier
                ) {
                    Text(text = "❌", textAlign = TextAlign.Center, modifier = textWidthModifier)
                }
            }
        }
    )
}

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,319评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,801评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,567评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,156评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,019评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,090评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,500评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,192评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,474评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,566评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,338评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,212评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,572评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,890评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,169评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,478评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,661评论 2 335

推荐阅读更多精彩内容