可组合项 (Composable)
https://developer.android.google.cn/jetpack/compose/mental-model#simple-example
修饰符 (Modifier)
https://developer.android.google.cn/jetpack/compose/modifiers
△ Jetpack Compose 布局详情
布局模型
Compose 布局系统的目标是提供易于创建的布局,尤其是自定义布局。这要求布局系统具备强大的功能,使开发者能创建应用所需的任何布局,并且让布局具备优异的性能。接下来,我们来看看 Compose 的布局模型是如何实现这些目标的。
自定义布局
https://developer.android.google.cn/jetpack/compose/layouts/custom
Compose 的布局模型
https://developer.android.google.cn/jetpack/compose/layouts/basics#model
Jetpack Compose 可将状态转换为界面,这个过程分为三步: 组合、布局、绘制。组合阶段执行可组合函数,这些函数可以生成界面,从而创建界面树。例如,下图中的 SearchResult 函数会生成对应的界面树:
可组合函数
https://developer.android.google.cn/jetpack/compose/mental-model#simple-example
其过程简述如下:
Layout 可组合项
fun Layout(content: @Composable () -> Unit,modifier: Modifier = Modifier,measurePolicy: MeasurePolicy) {…}
fun MyCustomLayout(modifier: Modifier = Modifier,content: @Composable () -> Unit) {Layout(modifier = modifier,content = content) { measurables: List<Measurable>,constraints: Constraints ->// TODO 测量和放置项目}}
在 MyCustomLayout 可组合项中,我们调用 Layout 函数并以 Trailing Lambda 的形式提供 MeasurePolicy 作为参数,从而实现所需的 measure 函数。该函数接受一个 Constraints 对象来告知 Layout 它的尺寸限制。Constraints 是一个简单类,用于限制 Layout 的最大和最小宽度与高度:
class Constraints {val minWidth: Intval maxWidth: Intval minHeight: Intval maxHeight: Int}
△ Constraints
Trailing Lambda
https://kotlinlang.org/docs/lambdas.html#passing-trailing-lambdas
measure 函数还会接受 List<Measurable> 作为参数,这表示的是传入的子元素。Measurable 类型会公开用于测量项目的函数。如前所述,布局每个元素需要三步: 每个元素必须测量其所有子元素,并以此判断自身尺寸,再放置其子元素。其代码实现如下:
fun MyCustomLayout(content: @Composable () -> Unit,modifier: Modifier = Modifier) {Layout(modifier = modifier,content = content) { measurables: List<Measurable>,constraints: Constraints ->// placeables 是经过测量的子元素,它拥有自身的尺寸值val placeables = measurables.map { measurable ->// 测量所有子元素,这里不编写任何自定义测量逻辑,只是简单地// 调用 Measurable 的 measure 函数并传入 constraintsmeasurable.measure(constraints)}val width = // 根据 placeables 计算得出val height = // 根据 placeables 计算得出// 报告所需的尺寸layout (width, height) {placeables.foreach { placeable ->// 通过遍历将每个项目放置到最终的预期位置placeable.place(x = …y = …)}}}}
自定义布局示例
fun MyColumn(modifier: Modifier = Modifier,content: @Composable () -> Unit) {Layout(modifier = modifier,content = content) { measurables, constraints ->// 测量每个项目并将其转换为 Placeableval 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 = 0placeables.forEach { placeable ->placeable.placeRelative(x = 0, y = y)// 按照所放置项目的高度增加 y 坐标值y += placeable.height}}}}
△ 自定义 Column
△ 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,)// 使用这些约束测量每个项目并将其转换为 Placeableval placeables = measurables.map { it.measure(itemConstraints) }…}}
△ 自定义 VerticalGrid
在该示例中,我们通过 copy 函数创建了新的约束。这种为子节点创建新约束的概念就是实现自定义测量逻辑的方式。创建不同约束来测量子节点的能力是此模型的关键,父节点与子节点之间并没有协商机制,父节点会以 Constraints 的形式传递其允许子节点的尺寸范围,只要子节点从该范围中选择了其尺寸,父节点必须接受并处理子节点。
这种设计的优点在于我们可以单遍测量整棵界面树,并且禁止执行多个测量循环。这是 View 系统中存在的问题,嵌套结构执行多遍测量过程可能会让叶子视图上的测量次数翻倍,Compose 的设计能够防止发生这种情况。实际上,如果您对某个项目进行两次测量,Compose 会抛出异常:
△ 重复测量某个项目时 Compose 会抛出异常
布局动画示例
△ Jetsnack 应用中的自定义底部导航
fun BottomNavItem(icon: @Composable BoxScope.() -> Unit,text: BoxScope.() -> Unit,animationProgress: Float) {Layout(content = {// 将 icon 和 text 包裹在 Box 中// 这种做法能让我们为每个项目设置 layoutIdBox(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,animationProgress: Float): MeasureResult {// 根据动画进度值放置文本和图标val iconY = (height - iconPlaceable.height) / 2val textY = (height - textPlaceable.height) / 2val textWidth = textPlaceable.width * animationProgressval iconX = (width - textWidth - iconPlaceable.width) / 2val textX = iconX + iconPlaceable.widthreturn layout(width, height) {iconPlaceable.placeRelative(iconX.toInt(), iconY)if (animationProgress != 0f) {textPlaceable.placeRelative(textX.toInt(), textY)}}}
当您遇到以下场景时,我们推荐使用自定义布局:
难以通过标准布局实现的设计。虽然可以使用足够多的 Row 和 Column 构建大部分界面,但这种实现方式有时难以维护和升级;
需要非常精确地控制测量和放置逻辑;
需要实现布局动画。我们正在开发可对放置进行动画处理的新 API,未来可能不必自行编写布局就能实现;
需要完全控制性能。下文会详细介绍这一点。
修饰符
修饰符
https://developer.android.google.cn/jetpack/compose/modifiers
修饰符分很多不同的类型,可以影响不同的行为,例如绘制修饰符 (DrawModifier)、指针输入修饰符 (PointerInputModifier) 以及焦点修饰符 (FocusModifier)。本文我们将重点介绍布局修饰符 (LayoutModifier),该修饰符提供了一个 measure 方法,该方法的作用与 Layout 可组合项基本相同,不同之处在于,它只作用于单个 Measurable 而不是 List<Measurable>,这是因为修饰符的应用对象是单个项目。在 measure 方法中,修饰符可以修改约束或者实现自定义放置逻辑,就像布局一样。这表示您并不总是需要编写自定义布局,如果只想对单个项目执行操作,则可以改用修饰符。
修饰符分很多不同的类型
https://developer.android.google.cn/jetpack/compose/modifiers-list
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 = 50val 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 实现布局
假设这个 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 解析其尺寸并执行放置操作。
修饰符链的执行方式与布局树的工作方式非常相像,差异在于每个修饰符只有一个子节点,也就是链中的下一个元素。约束会向下传递,以便后续元素用其测量自身尺寸,然后返回解析得到的尺寸,并创建放置指令。该示例也说明了修饰符顺序的重要性。通过使用修饰符对功能进行组合,您可以很轻松地将不同的测量和布局策略组合在一起。
修饰符顺序的重要性
https://developer.android.google.cn/jetpack/compose/modifiers#order-modifier-matters
高级功能
这里确定了 Column 会尽力为每个子节点提供所需的空间,对 Text 而言,其宽度是单行渲染全部文本所需的宽度。在确定固有尺寸后,将使用这些值设置 Column 的尺寸,然后,子节点就可以填充 Column 的宽度了。
如果使用最小值而非最大值,又会发生什么呢?
Jetpack Compose 中的布局 Codelab
https://developer.android.google.cn/codelabs/jetpack-compose-layouts#10
ParentData
到目前为止,我们看到的修饰符都是通用修饰符,也就是说,它们可以应用于任何可组合项。有时,您的布局提供的一些行为可能需要从子节点获得一些信息,这便要用到 ParentDataModifier。
ParentDataModifier
https://developer.android.google.cn/reference/kotlin/androidx/compose/ui/layout/ParentDataModifier
△ 在 BoxScope 中可以改用 Align 修饰符来定位内容
Align 是一个 ParentDataModifier 而不是我们之前看到的那种布局修饰符,因为它只是向其父节点传递一些信息,所以如果不在 Box 中,该修饰符便不可用。它包含的信息将提供给父 Box,以供其设置子布局。
△ 需要实现设计图中的图标和文本对齐
Row {= Modifierdp).align(Alignment.CenterVertically))= Modifier= 8.dp).align(Alignment.CenterVertically))}
△ 有问题的对齐实现
图标底部没有落在文本基线上
我们可以通过以下代码进行修复:
Row {= Modifierdp){ 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: Constraintsval minWidth: Dpval maxWidth: Dpval minHeight: Dpval maxHeight: Dp}
△ BoxWithConstraints 和 BoxWithConstraintsScope
它内部的内容可以使用这些约束来选择要组合的内容。例如,根据最大宽度选择不同的呈现方式:
fun MyApp(...) {BoxWithConstraints() { // this: BoxWithConstraintsScopewhen {maxWidth < 400.dp -> CompactLayout()maxWidth < 800.dp -> MediumLayout()else -> LargeLayout()}}}
性能
尽量避免重组
△ 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 等其他组件中可以观察滚动位置,而我们的观察方式会对性能产生影响。例如,使用最直接的实现,简单地使用滚动值对内容进行偏移:
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: BoxScope.() -> Unit,animationProgress: () -> Float) {…val progress = animationProgress()val textWidth = textPlaceable.width * progressval iconX = (width - textWidth - iconPlaceable.width) / 2val textX = iconX + iconPlaceable.widthreturn layout(width, height) {iconPlaceable.placeRelative(iconX.toInt(), iconY)if (animationProgress != 0f) {textPlaceable.placeRelative(textX.toInt(), textY)}}}
△ 修正后的底部导航
BoxWithConstraints 可以根据布局执行组合,是因为它会在布局阶段启动子组合。出于性能考虑,我们希望尽量避免在布局期间执行组合。因此,相较于 BoxWithConstraints,我们倾向于使用会根据尺寸更改的布局。当信息类型随尺寸更改时才使用 BoxWithConstraints。
提高布局性能
有时候,布局不需要测量其所有子节点便可获知自身大小。举个例子,有如下构成的卡片:
图标和标题构成标题栏,剩下的是正文。已知图标大小为固定值,标题高度与图标高度相同。测量卡片时,就只需要测量正文,它的约束就是布局高度减去 48 DP,卡片的高度则为正文的高度加上 48 DP。
△ 放置过程测量图标和文本
△ 标题发生变化时不必重新测量
总结
在本文中,我们介绍了自定义布局的实现过程,还使用修饰符构建和合并布局行为,进一步降低了满足确切功能需求的难度。此外,还介绍了布局系统的一些高级功能,例如跨嵌套层次结构的自定义对齐,为自有布局创建自定义 ParentDataModifier,支持自动从右向左设置,以及将组合操作推迟到布局信息已知时,等等。我们还了解如何执行单遍布局模型,如何跳过重新测量以使其只执行重新放置操作的方法,熟练使用这些方法,您将能编写出通过手势进行动画处理的高性能布局逻辑。
对布局系统的理解能够帮助您构建满足确切设计需求的布局,从而创建用户喜爱的优秀应用。如需了解更多,请查阅以下列出的资源:
https://developer.android.google.cn/courses/android-basics-kotlin/course
推荐阅读