原文链接。
MAD 技能:Compose 布局 和 修饰符 第 4 集
欢迎回到 关于 Jetpack Compose 布局 和 修饰符 的 MAD 技能系列!在上一集,我们讨论了 Compose 的 Layout 阶段,以解释 修饰符链的顺序 和 传入的父级约束 是如何影响 它们所传递给的 可组合项的。
今天这一集,我们进一步聚焦 布局阶段 和 约束,并从另一个角度介绍它们——如何利用它们的力量 在 Compose 中 构建 自定义布局。
为了建自定义布局,我们会介绍布局阶段 能够做 什么、如何 进入它 以及如何 使用其子阶段(测量和放置)来发挥您的优势,用于构建灵活的自定义布局。
之后,我们将介绍两个重要的、违反规则的 Compose API:SubcomposeLayout
和 Intrinsic 测量 ,作为布局难题的最后两个缺失部分。这些概念将为您提供额外的知识,以在 Compose 中构建 具有非常具体要求的复杂设计。
您也可以将本文作为 MAD 技能视频观看:
Compose 的所有布局
在前几集中,我们讨论了 Compose 如何通过其三个阶段 将数据转换为 UI:组合、布局和绘制,或显示 “什么”、放在 “哪里” 以及 “如何” 呈现它 .
但正如我们系列的名称所暗示的那样,我们最感兴趣的是 布局阶段。
但是,Compose 中的术语 “布局(Layout)” 用于许多不同的事物,并且由于其含义众多,从而看起来可能令人困惑。到目前为止,在本系列中,我们已经学习了以下用法:
- 布局阶段:Compose 的三个阶段之一,其中父布局定义其子元素的大小和位置
- 布局:一个广泛的抽象术语,用于在 Compose 中快速定义任何 UI 元素
- 布局节点: 一个抽象概念,用作 UI 树中一个元素的可视化表示,被创建为 Compose 中的 组合 阶段的结果。
在这一集中,我们还将学习一些额外的含义来完成整个布局循环。让我们先快速分解它们——对这些术语的更深入解释,稍后将在帖子中进一步介绍:
Layout
可组合项: 用作 Compose UI 核心组件的可组合项。在 组合(Composition)期间调用时,它会在 Compose UI 树中创建并添加一个布局节点;所有更高级别布局的基础,如Column
、Row
等。layout()
函数 - 放置的起点,这是布局阶段的第二个子步骤,负责在测量的第一个子步骤之后,将子项放置在Layout
可组合项中.layout()
modifier — 一种修饰符,它包裹一个布局节点,并允许单独调整它的大小和放置它,而不是由其父布局完成
现在我们知道什么(对应上文中的 “显示 ‘什么’ ”)是什么了,让我们从 布局阶段 开始,同时 并放大 它(布局阶段)。如前所述,在布局阶段,UI 树中的每个元素 测量其子元素(如果有),并 将它们放置 在可用的 2D 空间中。
Compose 中的每个开箱即用的布局,例如Row
、Column
等等,都会为您 自动地 处理所有这些。
但是,如果您的设计需要 非标准布局,那您需要自定义并构建自己的布局,例如来自我们的 JetLagged 示例 的TimeGraph
?
这正是您需要更多地了解布局阶段的时候——如何 进入它(布局阶段) 以及如何利用它的子元素 测量和放置 的子阶段,才会对您有利。那么,让我们来看看如何根据给定的设计 在 Compose 中 构建 自定义布局!
进入布局阶段矩阵
让我们回顾一下构建自定义布局的最重要、最基本的步骤。但是,如果您希望遵循 详细、分步的视频指南,了解如何以及何时为现实生活中复杂的 app 设计创建自定义布局,请查看在 Compose 视频中的自定义布局和图形, 或直接从我们的 JetLagged 示例 ,探索TimeGraph
自定义布局。
调用 Layout
可组合项是布局阶段和构建自定义布局的起点:
@Composable
fun CustomLayout(
content: @Composable () -> Unit,
modifier: Modifier = Modifier
) {
Layout() { … }
}
Layout
可组合项是 Compose 中 布局 阶段的 主角,也是 Compose 布局系统的 核心组件:
@Composable inline fun Layout(
content: @Composable @UiComposable () -> Unit,
modifier: Modifier = Modifier,
measurePolicy: MeasurePolicy
) {
// …
}
它接受一个可组合的 content
作为其子项,并接受一个用于测量和定位其元素的 测量策略。所有更高级别的布局,例如 Column
和 Row
,都在底层使用这个可组合项。
Layout
可组合项当前具有三个重载:
Layout
— 用于测量和放置 0 个或多个子项,它接受一个可组合项作为content
Layout
— 用于 UI 树的叶节点恰好有 0 个子节点,因此它没有content
参数Layout
- 接受用于传递多个不同可组合项的contents
列表
一旦我们进入布局阶段,我们就会看到它由两个步骤组成,测量和放置,按特定顺序排列:
@Composable
fun CustomLayout(
content: @Composable () -> Unit,
modifier: Modifier = Modifier
) {
Layout(
content = content,
modifier = modifier,
measurePolicy = { measurables, constraints ->
// 1. 测量 步骤
// 确定组件的大小
layout(…) {
// 2. 放置 步骤
// 确定组件的位置
}
}
)
}
子元素的 大小 在 测量过程 中计算,它们的位置在 放置过程 中计算。这些步骤的顺序是 通过 Kotlin DSL 作用域强制执行的,这些作用域的嵌套方式可以防止放置尚未预先测量的内容或在 测量作用域 内进行放置:
@Composable
fun CustomLayout(
content: @Composable () -> Unit,
modifier: Modifier = Modifier
) {
Layout(
content = content,
modifier = modifier,
measurePolicy = { measurables, constraints ->
// 测量 作用域
// 1. 测量 步骤
// 确定组件的大小
layout(…) {
// 放置 作用域
// 2. 放置 步骤
// 确定组件的位置
}
}
)
}
在测量期间,布局的内容可以作为 measurables(可测量对象) 或 准备好被测量的 组件进行访问。在 布局 内部,measurables
默认以列表的形式出现:
@Composable
fun CustomLayout(
content: @Composable () -> Unit,
// …
) {
Layout(
content = content,
modifier = modifier
measurePolicy = { measurables: List<Measurable>, constraints: Constraints ->
// 测量 作用域
// 1. 测量 步骤
// 确定组件的大小
}
)
}
根据自定义布局的要求,您可以采用此列表并测量具有 相同传入约束 的每个项目,以保持其 原始的预定义大小:
@Composable
fun CustomLayout(
content: @Composable () -> Unit,
// …
) {
Layout(
content = content,
modifier = modifier
) { measurables, constraints ->
// 测量 作用域
// 1. 测量 步骤
measurables.map { measurable ->
measurable.measure(constraints)
}
}
}
或者根据需要调整其测量值——通过 复制 您希望保留的约束并 覆盖 您希望更改的约束:
@Composable
fun CustomLayout(
content: @Composable () -> Unit,
// …
) {
Layout(
content = content,
modifier = modifier
) { measurables, constraints ->
// 测量 作用域
// 1. 测量 步骤
measurables.map { measurable ->
measurable.measure(
constraints.copy(
minWidth = newWidth,
maxWidth = newWidth
)
)
}
}
}
在上一集中我们已经看到,在布局阶段,约束在 UI 树中 从父级传递到子级。当父节点测量其子节点时,它会向每个子节点提供这些约束,让他们知道 允许的最小和最大尺寸。
布局 阶段的一个非常重要的特征是 单遍测量。这意味着布局元素不能多次测量其任何子元素。单遍测量有利于性能,允许 Compose 有效地处理深层 UI 树。
测量一个 measurables(可测量对象)
列表,将返回一个 placeables(可放置对象)
列表,或者一个现在 准备好被放置的组件:
@Composable
fun CustomLayout(
content: @Composable () -> Unit,
// …
) {
Layout(
content = content,
modifier = modifier
) { measurables, constraints ->
// 测量 作用域
// 1. 测量 步骤
val placeables = measurables.map { measurable ->
// 返回一个 placeable(可放置对象)
measurable.measure(constraints)
}
}
}
放置步骤从调用layout()
函数并进入放置作用域开始。此时,父布局将能够 决定自己的大小 (totalWidth
,totalHeight
),例如,将其子 可放置对象 的宽度和高度相加:
@Composable
fun CustomLayout(
// …
) {
Layout(
// …
) {
// totalWidth 可以是所有子项宽度的总和
// totalHeight 可以是所有子项高度的总和
layout(totalWidth, totalHeight) {
// 放置 作用域
// 2. 放置 步骤
}
}
}
放置范围现在允许我们使用作为 测量结果 出现的所有 placeables(可放置对象)
:
@Composable
fun CustomLayout(
// …
) {
Layout(
// …
) {
// …
layout(totalWidth, totalHeight) {
// 放置 作用域
// 2. 放置 步骤
placeables // 放置我们! 😎
}
}
}
要开始放置子项,我们需要他们的起始 x 和 y 坐标。一旦我们定义了我们希望子项被放置的位置,我们就调用 place()
来结束放置过程:
@Composable
fun CustomLayout(
// …
) {
Layout(
// …
) {
// …
layout(totalWidth, totalHeight) {
// 放置 作用域
// 2. 放置 步骤
placeables.map { it.place(xPosition, yPosition) }
}
}
}
这样,我们就结束了放置步骤,以及布局阶段!您的自定义布局现在可以使用和重用了。
.layout()
用于所有单个元素的修饰符
使用 Layout 可组合项创建自定义布局使您能够操纵 所有子元素 并 手动控制 它们的大小和位置。然而,在某些情况下,创建自定义布局只是为了控制 一个特定元素,这是一种矫枉过正的做法,而且没必要。
在这些情况下,Compose 没有使用自定义布局,而是提供了一种更好、更简单的解决方案 — .layout()
修饰符,它允许您仅测量和布局 一个被包裹的元素。
让我们看一个示例,其中 UI 元素,被它的父级以我们不太喜欢的方式压缩:
我们只希望这个简单 Column
中的一个 Element
,能够通过为其移除周围的 40.dp
的 padding
,强制它拥有比父级的宽度 更大的宽度,例如,以实现边对边的外观:
@Composable
fun LayoutModifierExample() {
Column(
modifier = Modifier
.fillMaxWidth()
.background(Color.LightGray)
.padding(40.dp)
) {
Element()
Element()
// 下面的 item 应该 “反抗” 强制的 padding,且遵从边对边(的规则)
Element()
Element()
}
}
为了让第三个元素控制自己并 移除 强制 padding,我们在其上设置了一个 .layout()
修饰符。
它的工作方式与 Layout
可组合项非常相似。它接受一个 lambda,该 lambda 允许您访问您正在测量的元素,(并)作为单个的 measurable(可测量对象)
,和可组合项(的)来自父级的 传入约束 来传递 。然后,您可以使用它来修改单个包装元素的测量和布局方式:
Modifier.layout { measurable, constraints ->
// 测量
val placeable = measurable.measure(...)
layout(placeable.width, placeable.height) {
// 放置
placeable.place(...)
}
}
回到我们的示例——然后我们在测量步骤中更改此 Element
的最大宽度,以添加额外的 80.dp
:
Element(modifier = Modifier.layout { measurable, constraints ->
val placeable = measurable.measure(
constraints.copy(
// 通过将 DP 添加到传入约束来调整此 item 的 maxWidth
maxWidth = constraints.maxWidth + 80.dp.roundToPx()
)
)
layout(placeable.width, placeable.height) {
// 把这个 item 放在原来的位置
placeable.place(0, 0)
}
})
正如我们之前所说,Compose 的优势之一是您可以在解决问题时 选择自己的途径,因为条条大路通罗马。如果您知道此元素所需的 确切静态大小,另一种方法可能是在其上设置 .requiredWidth()
修饰符,以便父布局中的传入约束 不会覆盖 其设置的宽度,而是 “尊重”它。相反,使用常规的 .width()
修饰符会使设置的宽度被父布局和测量阶段的传入约束覆盖。
SubcomposeLayout — 打破 Compose 各个阶段 的规则
在前面几集中,我们谈到了 Compose的各个阶段 以及它们精确排序的规则:1. 组合,2. 布局,3. 绘制。布局阶段随后分解为 测量和放置 子阶段。虽然这适用于绝大多数 Layout
可组合项,却有一个打破规则的布局不遵循此架构,但有一个很好的理由 — SubcomposeLayout
。
考虑以下用例——您正在构建一个包含一千个 item 的列表,而这些 item 根本 无法 同时 容纳在屏幕上。在那种情况下,组合所有这些子 item 将是不必要的资源浪费——如果其中大部分甚至都看不到,那为什么要预先组合这么多 item 呢?
相反,更好的方法是1。测量子项 以获得它们的大小,然后在此基础上,2。计算可用视口(可以理解为屏幕上的可见区域)中 可以容纳的 item 数,最后只组合 可见 的 item。
这是SubcomposeLayout
背后的主要思想之一——它需要 首先 对部分或所有子可组合项进行 测量,然后使用该信息来 确定是否组合 部分或所有子项。
这正是 Lazy 组件 构建在SubcomposeLayout
之上的原因,这使它们能够在滚动时按需添加内容。
SubcomposeLayout
将组合阶段推迟到布局阶段,因此可以推迟某些子可组合项的组合或执行,直到父布局具有更多信息(例如,其子可组合项的大小)。也就是说,布局阶段的测量步骤需要 先于 组合阶段进行。
BoxWithConstraints
也在底层使用 SubcomposeLayout
,但这个用例略有不同—— BoxWithConstraints
允许您 获取 父级传递的 约束,并在 延迟的组合阶段 使用它们,因为约束仅在布局阶段测量步骤中已知:
BoxWithConstraints {
// maxHeight 是仅在 BoxWithConstraints 中可用的测量信息,
// 因为延迟的组合阶段发生在布局阶段测量【之后】
if (maxHeight < 300.dp) {
SmallImage()
} else {
BigImage()
}
}
(什么时候要)禁止(使用)SubcompositionLayout
由于 SubcompositionLayout
改变了 Compose 各个阶段的 常规的流程 以允许动态执行,因此在性能方面存在一定的 成本和限制。因此,了解何时应该使用 SubcompositionLayout
以及何时不该使用它非常重要。
了解何时可能需要 SubcomposeLayout 的一种快速的好方法是,至少 一个子可组合项的组合阶段取决于另一个子可组合项的测量结果。我们已经在 Lazy 组件和 BoxWithConstraints
中看到了有效的用例。
但是,如果您只需要 一个子项的测量值来测量其他子项,则可以使用常规 Layout
可组合项来实现。这样,您仍然可以根据彼此的结果分别测量 item ——您只是不能改变它们的组合。
Intrinsic 测量 — 打破单遍测量规则
我们之前提到的第二个 Compose 规则是 布局 阶段的 单遍测量,这对该步骤和布局系统的总体性能有很大帮助。想一想短时间内可能发生的 重组数量,以及限制每次重组的整个 UI 树的测量,这些都将大幅提高整体速度!
为每次重组,遍历具有大量 UI 节点的树
但是,在某些用例中,父布局 需要 在测量子布局之前 了解 有关其子布局的 一些信息,以便它可以使用此信息来 定义和传递约束。而这正是 Intrinsic 测量的用途所在——让你 在子项被测量之前,能够事先查询子项(的相关信息)。
让我们看看下面的例子——我们希望这列 Column
items 具有相同的宽度,或者更准确地说,让每个 item 的宽度与最宽的子项(在我们的例子中,指 “And Modifiers” 这个 item)的宽度相同。但是,我们也希望最宽子项按需获得尽可能多的宽度。所以我们的第一步是:
@Composable
fun IntrinsicExample() {
Column() {
Text(text = "MAD")
Text(text = "Skills")
Text(text = "Layouts")
Text(text = "And Modifiers")
}
}
但是,我们可以看到,这还不够。每个 itme 只占用它需要的空间。我们可以尝试以下方法:
@Composable
fun IntrinsicExample() {
Column() {
Text(text = "MAD", Modifier.fillMaxWidth())
Text(text = "Skills", Modifier.fillMaxWidth())
Text(text = "Layouts", Modifier.fillMaxWidth())
Text(text = "And Modifiers", Modifier.fillMaxWidth())
}
}
但是,这会将每个 item 和父 Column
扩展至屏幕上可用的最大宽度。请记住,我们想要所有 item 的都有 最宽 item 的宽度。所以,如你所知,我们的目标是在这里使用 Intrinsics:
@Composable
fun IntrinsicExample() {
Column(Modifier.width(IntrinsicSize.Max)) {
Text(text = "MAD", Modifier.fillMaxWidth())
Text(text = "Skills", Modifier.fillMaxWidth())
Text(text = "Layouts", Modifier.fillMaxWidth())
Text(text = "And Modifiers", Modifier.fillMaxWidth())
}
}
通过在父 Column
上使用 IntrinsicSize.Max
,我们查询它的子项并询问 “想要恰当地显示所有内容,所需的 最大宽度 是多少?”。由于我们正在显示文本,并且短语 “And Modifiers” 最长,因此它将定义 Column
的宽度。
一旦确定了固有大小,它就会用于 设置 Column
的 大小(在本例中为宽度),然后其余子项就可以填满该宽度。
相反,如果我们使用 IntrinsicSize.Min
,问题将是“要恰当地显示所有内容,所需的 最小宽度 是多少?” 在文本的情况下,最小固有宽度是每行有一个单词的宽度:
@Composable
fun IntrinsicExample() {
Column(Modifier.width(IntrinsicSize.Min) {
Text(text = "MAD", Modifier.fillMaxWidth())
Text(text = "Skills", Modifier.fillMaxWidth())
Text(text = "Layouts", Modifier.fillMaxWidth())
Text(text = "And Modifiers", Modifier.fillMaxWidth())
}
}
快速总结所有可用的 intrinsic 组合:
Modifier.width(IntrinsicSize.Min)
— “要正确显示内容,所需的最小宽度是多少?”Modifier.width(IntrinsicSize.Max)
— “要正确显示内容,所需的最大宽度是多少?”Modifier.height(IntrinsicSize.Min)
— “要正确显示内容,所需的最小高度是多少?”Modifier.height(IntrinsicSize.Max)
— “要正确显示内容,所需的最大高度是多少?”
然而,Intrinsic 测量 不会真正地测量 子项两次。相反,它进行了一种不同类型的计算——您可以将其视为不需要指数测量时间的 预测量步骤,因为它 更轻量、更容易。 因此,虽然这并没有 完全 打破单一的测量规则,但它确实稍微改变了一点,并显示了一个超出常规要求的 Compose 要求。
创建自定义布局时,Intrinsics 提供基于 近似值 的 默认实现。但是,在某些情况下,默认计算可能无法按预期工作,因此 API 提供了一种 覆盖(重写) 这些默认值的方法。
要指定自定义布局的 Intrinsic 测量,您可以在测量过程中重写 MeasurePolicy
接口的 minIntrinsicWidth
、minIntrinsicHeight
、maxIntrinsicWidth
和 maxIntrinsicHeight
Layout(
modifier = modifier,
content = content,
measurePolicy = object : MeasurePolicy {
override fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult {
// 在这儿进行 测量 和 布局
}
override fun IntrinsicMeasureScope.maxIntrinsicHeight(
measurables: List<IntrinsicMeasurable>,
width: Int
): Int {
// 在这儿计算自定义 maxIntrinsicHeight 的逻辑
}
// 其他与 intrinsics 相关的方法都有默认值,
// 您可以只重写您需要的方法。
}
)
到这儿就结束了 🎬
我们今天涵盖了很多内容——术语 “Layout(布局)” 的所有 不同含义 以及它们之间的关系,在构建自定义布局时如何 进入和控制 布局阶段才能对您有利,然后我们总结了SubcompositionLayout
和 Intrinsic 测量 作为附加的 API 来实现非常具体的布局行为。
至此,我们结束了 MAD 技能 组合布局 和 修饰符 系列!在仅仅几集内容中,就涉及了从 布局 和 修饰符 的最基础的知识,到提供简单而强大的 Compose 布局、Compose 的各个阶段,再到 修饰符链的顺序 和 subcomposition(子组合) 等高级概念 - 恭喜,您已经取得了长足的进步!
我们希望您已经了解了有关 Compose 的新知识,并更新了旧知识,最重要的是 — 您感到更有准备和信心,将 所有内容 迁移到 Compose 😀。
这篇博文是系列博文的一部分:
第 1 集:Compose 布局 和 修饰符 的基础知识
第 2 集:Compose 的各个阶段
第 3 集:约束 和 修饰符 顺序
第 4 集:高级布局概念