Jetpack Compose 是用于构建原生 Android 界面的新工具包。它可简化并加快 Android 上的界面开发,使用更少的代码、强大的工具和直观的 Kotlin API,快速让应用生动而精彩。Compose 使用全新的组件——可组合项 (Composable) 来布局界面,使用修饰符 (Modifier) 来配置可组合项。
本文会为您讲解由可组合项和修饰符提供支持的组合布局模型,并深入探究其背后的工作原理以及它们的功能,让您更好地了解所用布局和修饰符的工作方式,和应如何以及在何时构建自定义布局,从而实现满足确切应用需求的设计。
布局模型
Compose 布局系统的目标是提供易于创建的布局,尤其是自定义布局。这要求布局系统具备强大的功能,使开发者能创建应用所需的任何布局,并且让布局具备优异的性能。接下来,我们来看看 Compose 的布局模型是如何实现这些目标的。Jetpack Compose 可将状态转换为界面,这个过程分为三步: 组合、布局、绘制。组合阶段执行可组合函数,这些函数可以生成界面,从而创建界面树。例如,下图中的 SearchResult 函数会生成对应的界面树:
其过程简述如下:
Layout 可组合项
我们已经了解这个过程涉及的步骤,接下来看一下它的实现方式。先看看组合阶段,我们采用 Row、Column、Text 等更高级别的可组合项来表示界面树,每个高级别的可组合项实际上都是由低级别的可组合项构建而成。以 Text 为例,可以发现它由若干更低级别的基础构建块组成,而这些可组合项都会包含一个或多个 Layout 可组合项。△ Layout 可组合项的函数签名fun Layout(
content: @Composable () -> Unit,
modifier: Modifier = Modifier,
measurePolicy: MeasurePolicy
) {
…
}
△ 实现 MeasurePolicy 函数式接口fun MyCustomLayout(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) { measurables: List,
constraints: Constraints ->
// TODO 测量和放置项目
}
}
在 MyCustomLayout 可组合项中,我们调用 Layout 函数并以 Trailing Lambda 的形式提供 MeasurePolicy 作为参数,从而实现所需的 measure 函数。该函数接受一个 Constraints 对象来告知 Layout 它的尺寸限制。Constraints 是一个简单类,用于限制 Layout 的最大和最小宽度与高度:
class Constraints {
val minWidth: Int
val maxWidth: Int
val minHeight: Int
val maxHeight: Int
}
△ Constraints
measure 函数还会接受 List 作为参数,这表示的是传入的子元素。Measurable 类型会公开用于测量项目的函数。如前所述,布局每个元素需要三步: 每个元素必须测量其所有子元素,并以此判断自身尺寸,再放置其子元素。其代码实现如下:
△ 布局每个元素的代码示例fun MyCustomLayout(
content: @Composable () -> Unit,
modifier: Modifier = Modifier
) {
Layout(
modifier = modifier,
content = content
) { measurables: List,
constraints: Constraints ->
// placeables 是经过测量的子元素,它拥有自身的尺寸值
val placeables = measurables.map { measurable ->
// 测量所有子元素,这里不编写任何自定义测量逻辑,只是简单地
// 调用 Measurable 的 measure 函数并传入 constraints
measurable.measure(constraints)
}
val width = // 根据 placeables 计算得出
val height = // 根据 placeables 计算得出
// 报告所需的尺寸
layout (width, height) {
placeables.foreach { placeable ->
// 通过遍历将每个项目放置到最终的预期位置
placeable.place(
x = …
y = …
)
}
}
}
}
自定义布局示例
MyColumn 示例
fun MyColumn(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
// 测量每个项目并将其转换为 Placeable
val placeables = measurables.map { measurable ->
measurable.measure(constraints)
}
// Column 的高度是所有项目所测得高度之和
val height = placeables.sumOf { it.height }
// Column 的宽度则为内部所含最宽项目的宽度
val width = placeables.maxOf { it.width }
// 报告所需的尺寸
layout (width, height) {
// 通过跟踪 y 坐标放置每个项目
var y = 0
placeables.forEach { placeable ->
placeable.placeRelative(x = 0, y = y)
// 按照所放置项目的高度增加 y 坐标值
y += placeable.height
}
}
}
}
△ 自定义 Column
VerticalGrid 示例
△ VerticalGrid
我们再来看另一个示例: 构建常规网格。其部分代码实现如下:fun VerticalGrid(
modifier: Modifier = Modifier,
columns: Int = 2,
content: @Composable () -> Unit
) {
Layout(
content = content,
modifier = modifier
) { measurables, constraints ->
val itemWidth = constraints.maxWidth / columns
// 通过 copy 函数保留传递下来的高度约束,但设置确定的宽度约束
val itemConstraints = constraints.copy (
minWidth = itemWidth,
maxWidth = itemWidth,
)
// 使用这些约束测量每个项目并将其转换为 Placeable
val placeables = measurables.map { it.measure(itemConstraints) }
…
}
}
△ 自定义 VerticalGrid
在该示例中,我们通过 copy 函数创建了新的约束。这种为子节点创建新约束的概念就是实现自定义测量逻辑的方式。创建不同约束来测量子节点的能力是此模型的关键,父节点与子节点之间并没有协商机制,父节点会以 Constraints 的形式传递其允许子节点的尺寸范围,只要子节点从该范围中选择了其尺寸,父节点必须接受并处理子节点。
这种设计的优点在于我们可以单遍测量整棵界面树,并且禁止执行多个测量循环。这是 View 系统中存在的问题,嵌套结构执行多遍测量过程可能会让叶子视图上的测量次数翻倍,Compose 的设计能够防止发生这种情况。实际上,如果您对某个项目进行两次测量,Compose 会抛出异常:
△ 重复测量某个项目时 Compose 会抛出异常
布局动画示例
由于具备更强的性能保证,Compose 提供了新的可能性,例如为布局添加动画。Layout composable 不仅可以创建通用布局,还能创建出符合应用设计需求的专用布局。以 Jetsnack 应用中的自定义底部导航为例,在该设计中,如果某项目被选中,则显示标签;如果未被选中,则只显示图标。而且,设计还需要让项目的尺寸和位置根据当前选择状态执行动画。△ Jetsnack 应用中的自定义底部导航
我们可以使用自定义布局来实现该设计,从而对布局变化的动画处理进行精确控制:△ 自定义底部导航fun BottomNavItem(
icon: @Composable BoxScope.() -> Unit,
text: Unit, BoxScope.() ->
Float animationProgress:
) {
Layout(
content = {
// 将 icon 和 text 包裹在 Box 中
// 这种做法能让我们为每个项目设置 layoutId
Box(
modifier = Modifier.layoutId(“icon”)
content = icon
)
Box(
modifier = Modifier.layoutId(“text”)
content = text
)
}
) { measurables, constraints ->
// 通过 layoutId 识别对应的 Measurable,比依赖项目的顺序更可靠
val iconPlaceable = measurables.first {it.layoutId == “icon” }.measure(constraints)
val textPlaceable = measurables.first {it.layoutId == “text” }.measure(constraints)
// 将放置逻辑提取到另一个函数中以提高代码可读性
placeTextAndIcon(
textPlaceable,
iconPlaceable,
constraints.maxWidth,
constraints.maxHeight,
animationProgress
)
}
}
fun MeasureScope.placeTextAndIcon(
textPlaceable: Placeable,
iconPlaceable: Placeable,
width: Int,
height: Int,
Float animationProgress:
): MeasureResult {
// 根据动画进度值放置文本和图标
val iconY = (height - iconPlaceable.height) / 2
val textY = (height - textPlaceable.height) / 2
val textWidth = textPlaceable.width * animationProgress
val iconX = (width - textWidth - iconPlaceable.width) / 2
val textX = iconX + iconPlaceable.width
return layout(width, height) {
iconPlaceable.placeRelative(iconX.toInt(), iconY)
if (animationProgress != 0f) {
textPlaceable.placeRelative(textX.toInt(), textY)
}
}
}
使用自定义布局的时机
当您遇到以下场景时,我们推荐使用自定义布局:
难以通过标准布局实现的设计。虽然可以使用足够多的 Row 和 Column 构建大部分界面,但这种实现方式有时难以维护和升级;
需要非常精确地控制测量和放置逻辑;
需要实现布局动画。我们正在开发可对放置进行动画处理的新 API,未来可能不必自行编写布局就能实现;
需要完全控制性能。下文会详细介绍这一点。
修饰符分很多不同的类型,可以影响不同的行为,例如绘制修饰符 (DrawModifier)、指针输入修饰符 (PointerInputModifier) 以及焦点修饰符 (FocusModifier)。本文我们将重点介绍布局修饰符 (LayoutModifier),该修饰符提供了一个 measure 方法,该方法的作用与 Layout 可组合项基本相同,不同之处在于,它只作用于单个 Measurable 而不是 List,这是因为修饰符的应用对象是单个项目。在 measure 方法中,修饰符可以修改约束或者实现自定义放置逻辑,就像布局一样。这表示您并不总是需要编写自定义布局,如果只想对单个项目执行操作,则可以改用修饰符。
以 padding 修饰符为例,该工厂函数以修饰符链为基础,创建能够捕获所需 padding 值的 PaddingModifier 对象。
△ padding 修饰符的实现除了通过上例中的方式覆写 measure 方法实现测量,您也可以使用 Modifier.layout,在无需创建自定义布局的情况下直接通过修饰符链向任意可组合项添加自定义测量和放置逻辑,如下所示:fun Modifier.padding(all: Dp) =
this.then(PaddingModifier(
start = all,
top = all,
end = all,
bottom = all
)
)
private class PaddingModifier(
val start: Dp = 0.dp,
val top: Dp = 0.dp,
val end: Dp = 0.dp,
val bottom: Dp = 0.dp
) : LayoutModifier {
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
): MeasureResult {
val horizontal = start.roundToPx() + end.roundToPx()
val vertical = top.roundToPx() + bottom.roundToPx()
// 按 padding 尺寸收缩外部约束来修改测量
val placeable = measurable.measure(constraints.offset(-horizontal, -vertical))
val width = constraints.constrainWidth(placeable.width + horizontal)
val height = constraints.constrainHeight(placeable.height + vertical)
return layout(width, height) {
// 按所需的 padding 执行偏移以放置内容
placeable.placeRelative(start.roundToPx(), top.roundToPx())
}
}
}
Box(Modifier
.background(Color.Gray)
.layout { measurable, constraints ->
// 通过修饰符在竖直方向添加 50 像素 padding 的示例
val padding = 50
val placeable = measurable.measure(constraints.offset(vertical = -padding))
layout(placeable.width, placeable.height + padding) {
placeable.placeRelative(0, padding)
}
}
) {
Box(Modifier.fillMaxSize().background(Color.DarkGray))
}
△ 使用 Modifier.layout 实现布局
虽然 Layout 接受单个 Modifier 参数,该参数会建立一个按顺序应用的修饰符链。我们通过示例来了解它与布局模型的交互方式。我们将分析下图修饰符的效果及其工作原理:假设这个 Box 要放入最大尺寸为 200*300 像素的容器内,容器会将相应的约束传入修饰符链的第一个修饰符中。fillMaxSize 实际上会创建一组新约束,并设置最大和最小宽度与高度,使之等于传入的最大宽度与高度以便填充到最大值,在本例中是 200*300 像素。这些约束沿着修饰符链传递以测量下一个元素,wrapContentSize 修饰符会接受这些参数,它会创建新的约束来放宽对传入约束的限制,从而让内容测量其所需尺寸,也就是宽 0-200,高 0-300。这看起来只像是对 fillMax 步骤的反操作,但请注意,我们是使用这个修饰符实现项目居中的效果,而不是重设项目的尺寸。这些约束沿着修饰符链传递到 size 修饰符,该修饰符创建具体尺寸的约束来测量项目,指定尺寸应该正好是 50*50。最后,这些约束传递到 Box 的布局,它执行测量并将解析得到的尺寸 (50*50) 返回到修饰符链,size 修饰符因此也将其尺寸解析为 50*50,并据此创建放置指令。然后 wrapContent 解析其大小并创建放置指令以居中放置内容。因为 wrapContent 修饰符知道其尺寸为 200*300,而下一个元素的尺寸为 50*50,所以使用居中对齐创建放置指令,以便将内容居中放置。最后,fillMaxSize 解析其尺寸并执行放置操作。
修饰符链的执行方式与布局树的工作方式非常相像,差异在于每个修饰符只有一个子节点,也就是链中的下一个元素。约束会向下传递,以便后续元素用其测量自身尺寸,然后返回解析得到的尺寸,并创建放置指令。该示例也说明了修饰符顺序的重要性。通过使用修饰符对功能进行组合,您可以很轻松地将不同的测量和布局策略组合在一起。
高级功能
这里确定了 Column 会尽力为每个子节点提供所需的空间,对 Text 而言,其宽度是单行渲染全部文本所需的宽度。在确定固有尺寸后,将使用这些值设置 Column 的尺寸,然后,子节点就可以填充 Column 的宽度了。
如果使用最小值而非最大值,又会发生什么呢?
ParentData
到目前为止,我们看到的修饰符都是通用修饰符,也就是说,它们可以应用于任何可组合项。有时,您的布局提供的一些行为可能需要从子节点获得一些信息,这便要用到 ParentDataModifier。
我们回到前面那个在父节点中居中放置蓝色 Box 的示例。这一次,我们将这个 Box 放在另一个 Box 中。Box 中的内容在一个称为 BoxScope 的接收器作用域内排布。BoxScope 定义了只在 Box 内可用的修饰符,它提供了一个名为 Align 的修饰符。这个修饰符刚好能够提供我们要应用到蓝色 Box 的功能。因此,如果我们知道蓝色 Box 位于另一个 Box 内,就可以改用 Align 修饰符来定位它。
△ 在 BoxScope 中可以改用 Align 修饰符来定位内容
Align 是一个 ParentDataModifier 而不是我们之前看到的那种布局修饰符,因为它只是向其父节点传递一些信息,所以如果不在 Box 中,该修饰符便不可用。它包含的信息将提供给父 Box,以供其设置子布局。
您也可以为自己的自定义布局编写 ParentDataModifier,从而允许子节点向父节点告知一些信息,以供父节点在布局时使用。 对齐线 (Alignment Lines)我们可以使用对齐线根据布局顶部、底部或中心以外的标准来设置对齐。最常用的对齐线是文本基线。假设需要实现这样一个设计:△ 需要实现设计图中的图标和文本对齐
Row {
Modifier =
dp)
.align(Alignment.CenterVertically)
)
Modifier =
8.dp) =
.align(Alignment.CenterVertically)
)
}
△ 有问题的对齐实现
仔细观察,会发现图标并没有像设计稿那样对齐在文本的基线上。我们可以通过以下代码进行修复:
△ 正确的对齐实现Row {
Modifier =
dp)
{ it.measuredHeight }
)
Modifier =
8.dp) =
.alignByBaseline()
)
}
首先,对 Text 使用 alignByBaseline 修饰符。而图标既没有基线,也没有其他对齐线,我们可以使用 alignBy 修饰符让图标对齐到我们需要的任何位置。在本例中,我们知道图标的底部是对齐的目标位置,因此将图标的底部进行对齐。最终便实现了期望的效果:
△ 图标底部与文本基线完美对齐
由于对齐功能会穿过父节点,因此,处理嵌套对齐时,只需设置父节点的对齐线,它会从子节点获取相应的值。如下例所示:
△ 未设置对齐的嵌套布局
△ 通过父节点设置对齐线
您甚至可以在自定义布局中创建自己的自定义对齐,从而允许其他可组合项对齐到它。
BoxWithConstraints
BoxWithConstraints 是一个功能强大且很实用的布局。在组合中,我们可以根据条件使用逻辑和控制流来选择要显示的内容,但是,有时候可能希望根据可用空间的大小来决定布局内容。
从前文中我们知道,尺寸信息直到布局阶段才可用,也就是说,这些信息一般无法在组合阶段用来决定要显示的内容。此时 BoxWithConstraints 便派上用场了,它与 Box 类似,但它将内容的组合推迟到布局阶段,此时布局信息已经可用了。BoxWithConstraints 中的内容在接收器作用域内排布,布局阶段确定的约束将通过该作用域公开为像素值或 DP 值。
fun BoxWithConstraints(
...
content: @Composable BoxWithConstraintsScope.() -> Unit
)
// BoxWithConstraintsScope 公开布局阶段确定的约束
interface BoxWithConstraintsScope : BoxScope {
val constraints: Constraints
val minWidth: Dp
val maxWidth: Dp
val minHeight: Dp
val maxHeight: Dp
}
△ BoxWithConstraints 和 BoxWithConstraintsScope
它内部的内容可以使用这些约束来选择要组合的内容。例如,根据最大宽度选择不同的呈现方式:
△ 在 BoxWithConstraintsScope 中根据最大宽度选择不同的布局fun MyApp(...) {
BoxWithConstraints() { // this: BoxWithConstraintsScope
when {
maxWidth < 400.dp -> CompactLayout()
maxWidth < 800.dp -> MediumLayout()
else -> LargeLayout()
}
}
}
性能
我们介绍了单遍布局模型如何防止在测量或放置方面花费过多时间,也演示了布局阶段两个不同的子阶段: 测量和放置。现在,我们将介绍性能相关的内容。尽量避免重组
单遍布局模型的设计效果是,任何只影响项目的放置而不影响测量的修改都可以单独执行。以 Jetsnack 为例:△ Jetsnack 应用中产品详情页的协调滚动效果
这个产品详情页包含协调滚动效果,页面上的一些元素根据滚动操作进行移动或缩放。请注意标题区域,这个区域会随着页面内容而滚动,最后固定在屏幕的顶部。
△ 详情页的大致实现fun SnackDetail(...) {
Box {
val scroll = rememberScrollState(0)
Body(scroll)
Title(scroll = scroll.value)
...
}
}
fun Body(scroll: ScrollState) {
Column(modifier = Modifier.verticalScroll(scroll)) {
…
}
}
为了实现此效果,我们将不同元素作为独立的可组合项叠放在一个 Box 中,提取滚动状态并将其传入 Body 组件。Body 会使用滚动状态进行设置以使内容能够垂直滚动。在 Title 等其他组件中可以观察滚动位置,而我们的观察方式会对性能产生影响。例如,使用最直接的实现,简单地使用滚动值对内容进行偏移:
△ 简单地使用滚动值偏移 Title 的内容fun Title(scroll: Int) {
Column(
modifier = Modifier.offset(scroll)
) {
…
}
}
fun Title(scrollProvider: () -> Int) {
Column(
modifier = Modifier.offset {
val scroll = scrollProvider()
val offset = (maxOffset - scroll).coerceAtLeast(minOffset)
IntOffset(x = 0, y = offset)
}
) {
…
}
}
△ 使用提供滚动位置的函数代替原始滚动位置
这时,我们可以在不同的时间只调用此 Lambda 函数并读取滚动状态。这里使用了 offset 修饰符,它接受能提供偏移值的 Lambda 函数作为参数。这意味着在滚动发生变化时,不需要重新创建修饰符,只在放置阶段才会读取滚动状态的值。所以,当滚动状态变化时我们只需要执行放置和绘制操作,不需要重组或测量,因此能够提高性能。
再回到底部导航的示例,它存在同样的问题,我们可以用相同方法加以修正:fun BottomNavItem(
icon: @Composable BoxScope.() -> Unit,
text: Unit, BoxScope.() ->
animationProgress: () -> Float
) {
…
val progress = animationProgress()
val textWidth = textPlaceable.width * progress
val iconX = (width - textWidth - iconPlaceable.width) / 2
val textX = iconX + iconPlaceable.width
return layout(width, height) {
iconPlaceable.placeRelative(iconX.toInt(), iconY)
if (animationProgress != 0f) {
textPlaceable.placeRelative(textX.toInt(), textY)
}
}
}
△ 修正后的底部导航
我们使用了能提供当前动画进度的函数作为参数,因此不需要重组,只执行布局即可。 您需要掌握一个原则: 只要可组合项或修饰符的参数可能频繁发生更改,都应当保持谨慎,因为这种情况可能导致过度组合。只有在更改显示内容时,才需要重组,更改显示位置或显示方式则不需要这么做。BoxWithConstraints 可以根据布局执行组合,是因为它会在布局阶段启动子组合。出于性能考虑,我们希望尽量避免在布局期间执行组合。因此,相较于 BoxWithConstraints,我们倾向于使用会根据尺寸更改的布局。当信息类型随尺寸更改时才使用 BoxWithConstraints。
提高布局性能
有时候,布局不需要测量其所有子节点便可获知自身大小。举个例子,有如下构成的卡片:
图标和标题构成标题栏,剩下的是正文。已知图标大小为固定值,标题高度与图标高度相同。测量卡片时,就只需要测量正文,它的约束就是布局高度减去 48 DP,卡片的高度则为正文的高度加上 48 DP。
△ 放置过程测量图标和文本
假设标题是 "Layout",当标题发生变化时,系统不必重新执行布局的测量操作,因此不会重新测量正文,从而省去不必要的工作。△ 标题发生变化时不必重新测量
总结在本文中,我们介绍了自定义布局的实现过程,还使用修饰符构建和合并布局行为,进一步降低了满足确切功能需求的难度。此外,还介绍了布局系统的一些高级功能,例如跨嵌套层次结构的自定义对齐,为自有布局创建自定义 ParentDataModifier,支持自动从右向左设置,以及将组合操作推迟到布局信息已知时,等等。我们还了解如何执行单遍布局模型,如何跳过重新测量以使其只执行重新放置操作的方法,熟练使用这些方法,您将能编写出通过手势进行动画处理的高性能布局逻辑。
对布局系统的理解能够帮助您构建满足确切设计需求的布局,从而创建用户喜爱的优秀应用。
原文标题:深度解析 Jetpack Compose 布局
文章出处:【微信公众号:谷歌开发者】欢迎添加关注!文章转载请注明出处。
全部0条评论
快来发表一下你的评论吧 !