Jetpack Compose:高级布局概念

3,186 阅读12分钟

原文链接

MAD 技能:Compose 布局 和 修饰符 第 4 集

1_rtPw9vMKV31rckXABZqKPA.webp

欢迎回到 关于 Jetpack Compose 布局 和 修饰符 的 MAD 技能系列!在上一集,我们讨论了 Compose 的 Layout 阶段,以解释 修饰符链的顺序传入的父级约束 是如何影响 它们所传递给的 可组合项的。

今天这一集,我们进一步聚焦 布局阶段 和 约束,并从另一个角度介绍它们——如何利用它们的力量 在 Compose 中 构建 自定义布局。

为了建自定义布局,我们会介绍布局阶段 能够做 什么、如何 进入它 以及如何 使用其子阶段(测量和放置)来发挥您的优势,用于构建灵活的自定义布局。

之后,我们将介绍两个重要的、违反规则的 Compose API:SubcomposeLayoutIntrinsic 测量 ,作为布局难题的最后两个缺失部分。这些概念将为您提供额外的知识,以在 Compose 中构建 具有非常具体要求的复杂设计

您也可以将本文作为 MAD 技能视频观看:

youtu.be/l6rAoph5UgI

Compose 的所有布局

在前几集中,我们讨论了 Compose 如何通过其三个阶段 将数据转换为 UI:组合、布局和绘制,或显示 “什么”、放在 “哪里” 以及 “如何” 呈现它 .

1_cuagNxGMWga6sL_cKC734g.webp

但正如我们系列的名称所暗示的那样,我们最感兴趣的是 布局阶段

但是,Compose 中的术语 “布局(Layout)” 用于许多不同的事物,并且由于其含义众多,从而看起来可能令人困惑。到目前为止,在本系列中,我们已经学习了以下用法:

  • 布局阶段:Compose 的三个阶段之一,其中父布局定义其子元素的大小和位置
  • 布局:一个广泛的抽象术语,用于在 Compose 中快速定义任何 UI 元素
  • 布局节点: 一个抽象概念,用作 UI 树中一个元素的可视化表示,被创建为 Compose 中的 组合 阶段的结果。

在这一集中,我们还将学习一些额外的含义来完成整个布局循环。让我们先快速分解它们——对这些术语的更深入解释,稍后将在帖子中进一步介绍:

  • Layout 可组合项: 用作 Compose UI 核心组件的可组合项。在 组合(Composition)期间调用时,它会在 Compose UI 树中创建并添加一个布局节点;所有更高级别布局的基础,如ColumnRow等。
  • layout()函数 - 放置的起点,这是布局阶段的第二个子步骤,负责在测量的第一个子步骤之后,将子项放置在Layout 可组合项中
  • .layout()modifier — 一种修饰符,它包裹一个布局节点,并允许单独调整它的大小和放置它,而不是由其父布局完成

现在我们知道什么(对应上文中的 “显示 ‘什么’ ”)是什么了,让我们从 布局阶段 开始,同时 并放大 它(布局阶段)。如前所述,在布局阶段,UI 树中的每个元素 测量其子元素(如果有),并 将它们放置 在可用的 2D 空间中。

1_Vzd_zFKnKbsjm8j0vfoNCQ.webp

Compose 中的每个开箱即用的布局,例如RowColumn等等,都会为您 自动地 处理所有这些。

但是,如果您的设计需要 非标准布局,那您需要自定义并构建自己的布局,例如来自我们的 JetLagged 示例TimeGraph?

0_FdVR4xy3aLvaxiZP.png

这正是您需要更多地了解布局阶段的时候——如何 进入它(布局阶段) 以及如何利用它的子元素 测量和放置 的子阶段,才会对您有利。那么,让我们来看看如何根据给定的设计 在 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() 函数并进入放置作用域开始。此时,父布局将能够 决定自己的大小 (totalWidthtotalHeight),例如,将其子 可放置对象 的宽度和高度相加:

@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 元素,被它的父级以我们不太喜欢的方式压缩:

1_imbbMp2Z1rSn2I0866Kmyw.webp

我们只希望这个简单 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)
  }
})

1_prYjYjy5SaDD4UumV9f_jA.webp

正如我们之前所说,Compose 的优势之一是您可以在解决问题时 选择自己的途径,因为条条大路通罗马。如果您知道此元素所需的 确切静态大小,另一种方法可能是在其上设置 .requiredWidth()  修饰符,以便父布局中的传入约束 不会覆盖 其设置的宽度,而是 “尊重”它。相反,使用常规的  .width()  修饰符会使设置的宽度被父布局和测量阶段的传入约束覆盖。

SubcomposeLayout — 打破 Compose 各个阶段 的规则

在前面几集中,我们谈到了 Compose的各个阶段 以及它们精确排序的规则:1. 组合,2. 布局,3. 绘制。布局阶段随后分解为 测量和放置 子阶段。虽然这适用于绝大多数 Layout 可组合项,却有一个打破规则的布局不遵循此架构,但有一个很好的理由 — SubcomposeLayout

考虑以下用例——您正在构建一个包含一千个 item 的列表,而这些 item 根本 无法 同时 容纳在屏幕上。在那种情况下,组合所有这些子 item 将是不必要的资源浪费——如果其中大部分甚至都看不到,那为什么要预先组合这么多 item 呢?

1_Ofj_w-mUps8mZOYnoP1tEw.webp

相反,更好的方法是1。测量子项 以获得它们的大小,然后在此基础上,2。计算可用视口(可以理解为屏幕上的可见区域)中 可以容纳的 item 数,最后只组合 可见 的 item。

1_HUc8NsIhG2KskA9edPhc-Q.webp

这是SubcomposeLayout背后的主要思想之一——它需要 首先 对部分或所有子可组合项进行 测量,然后使用该信息来 确定是否组合 部分或所有子项。

1_vY_6kdXlPy2h9ZqGSt3PJQ.webp

这正是 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 ——您只是不能改变它们的组合。

1_SobjLnADsF9uug1Hvsqg6g.webp

Intrinsic 测量 — 打破单遍测量规则

我们之前提到的第二个 Compose 规则是 布局 阶段的 单遍测量,这对该步骤和布局系统的总体性能有很大帮助。想一想短时间内可能发生的 重组数量,以及限制每次重组的整个 UI 树的测量,这些都将大幅提高整体速度!

1__hWJ6GmoW7W1FHyY2pj54g.webp

为每次重组,遍历具有大量 UI 节点的树

但是,在某些用例中,父布局 需要 在测量子布局之前 了解 有关其子布局的 一些信息,以便它可以使用此信息来 定义和传递约束。而这正是 Intrinsic 测量的用途所在——让你 在子项被测量之前,能够事先查询子项(的相关信息)

1_e-Zvn3ZxdsZAIW2XRxZ6FA.webp

让我们看看下面的例子——我们希望这列 Column items 具有相同的宽度,或者更准确地说,让每个 item 的宽度与最宽的子项(在我们的例子中,指 “And Modifiers” 这个 item)的宽度相同。但是,我们也希望最宽子项按需获得尽可能多的宽度。所以我们的第一步是:

@Composable
fun IntrinsicExample() {
    Column() {
        Text(text = "MAD")
        Text(text = "Skills")
        Text(text = "Layouts")
        Text(text = "And Modifiers")
    }
}

1_FeWpUJyLKsUOMIewBcchbA.webp

但是,我们可以看到,这还不够。每个 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())
    }
}

1_XudEVUtU0XEACyEtKOWRjw.webp

但是,这会将每个 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())
    }
}

1_xNnJb1bwhUAX_-AP_LzVrw.webp

通过在父 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())
    }
}

1_-63DuKNtaFTFBMi50g2c-g.webp

快速总结所有可用的 intrinsic 组合:

  • Modifier.width(IntrinsicSize.Min) — “要正确显示内容,所需的最小宽度是多少?”
  • Modifier.width(IntrinsicSize.Max) — “要正确显示内容,所需的最大宽度是多少?”
  • Modifier.height(IntrinsicSize.Min) — “要正确显示内容,所需的最小高度是多少?”
  • Modifier.height(IntrinsicSize.Max) — “要正确显示内容,所需的最大高度是多少?”

然而,Intrinsic 测量 不会真正地测量 子项两次。相反,它进行了一种不同类型的计算——您可以将其视为不需要指数测量时间的 预测量步骤,因为它 更轻量更容易。 因此,虽然这并没有 完全 打破单一的测量规则,但它确实稍微改变了一点,并显示了一个超出常规要求的 Compose 要求。

创建自定义布局时,Intrinsics 提供基于 近似值默认实现。但是,在某些情况下,默认计算可能无法按预期工作,因此 API 提供了一种 覆盖(重写) 这些默认值的方法。

要指定自定义布局的 Intrinsic 测量,您可以在测量过程中重写 MeasurePolicy 接口的 minIntrinsicWidthminIntrinsicHeightmaxIntrinsicWidthmaxIntrinsicHeight

    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(布局)” 的所有 不同含义 以及它们之间的关系,在构建自定义布局时如何 进入和控制 布局阶段才能对您有利,然后我们总结了SubcompositionLayoutIntrinsic 测量 作为附加的 API 来实现非常具体的布局行为。

至此,我们结束了 MAD 技能 组合布局 和 修饰符 系列!在仅仅几集内容中,就涉及了从 布局 和 修饰符 的最基础的知识,到提供简单而强大的 Compose 布局、Compose 的各个阶段,再到 修饰符链的顺序 和 subcomposition(子组合) 等高级概念 - 恭喜,您已经取得了长足的进步!

我们希望您已经了解了有关 Compose 的新知识,并更新了旧知识,最重要的是 — 您感到更有准备和信心,将 所有内容 迁移到 Compose 😀。

这篇博文是系列博文的一部分:

第 1 集:Compose 布局 和 修饰符 的基础知识
第 2 集:Compose 的各个阶段
第 3 集:约束 和 修饰符 顺序
第 4 集:高级布局概念