布局阶段用来对视图树中每个LayoutNode进行宽高尺寸测量并完成位置摆放。当Compose的内置组件无法满足我们的需求时,可以在定制组件的布局阶段实现满足自己需求的组件。
在学习定制布局阶段前,我们需要先了解Compose的布局原理。在Compose中,每个LayoutNode都会根据来自父LayoutNode的布局约束进行自我测量(类似传统View中的MeasureSpec)。布局约束中包含了父LayoutNode允许子LayoutNode的最大宽高与最小宽高,当父LayoutNode希望子LayoutNode测量的宽高为某个具体值时,约束中的最大宽高与最小宽高就是相同的。LayoutNode不允许被多次测量,在Compose中多次测量会抛异常,如图所示:
假设允许测量多次,我们对当前LayoutNode的子LayoutNode测量两次,而子LayoutNode可能又对它的子LayoutNode测量了两次,如果当前LayoutNode重新测量一次,则孙LayoutNode就需要测量四次,测量次数会随着视图树的深度增加而指数爆炸。Compose从框架层限制了每个LayoutNode测量次数,这样可以高效处理深度比较大的视图树(极端情况是退化成链表的树形结构)。
需要注意,有些需求场景仍然需要多次测量LayoutNode, Compose为我们提供了固有特性测量与SubcomposeLayout作为解决方案。
一、LayoutModifier
有时我们想在屏幕上展示一段文本,会用到Compose内置的Text组件。如果想设定Text顶部到文本基线的高度,使用内置的padding修饰符是无法满足需求的,因为padding只能指定Text顶部到文本顶部的高度,虽然Compose提供了paddingFromBaseline修饰符可以用来解决这个问题,但是不妨使用layout修饰符来重新实现一下,如图所示:
我们先简单了解一下什么是layout修饰符,layout修饰符是用来修饰LayoutNode的宽高与原有内容在新宽高下摆放位置的。
当使用layout修饰符时,我们传入的回调包含了两个信息:measurable与constraints。
kotlin
//源码
fun Modifier.layout(
measure: MeasureScope.(Measurable, Constraints) -> MeasureResult
) = this then LayoutModifierElement(measure)
//使用
Modifier.layout { measurable, constraints ->
...
}
measurable表示被修饰LayoutNode的测量句柄,通过内部measure方法完成LayoutNode测量。而constraints表示来自父LayoutNode的布局约束。接下来就看看在实际场景中该如何使用layout修饰符。
首先来创建一个firstBaselineToTop修饰符:
kotlin
fun Modifier.firstBaselineToTop(firstBaselineToTop: Dp) = this.then (
Modifier.layout { measurable, constraints ->
...
}
)
正如前面布局原理中所提到的,每个LayoutNode只允许被测量一次。可以使用measurable的measure()方法来测量LayoutNode,这里将constraints参数直接传入measure中,这说明我们是将父LayoutNode提供的布局约束直接提供给被修饰的LayoutNode进行测量了。测量结果会包装在Placeable实例中返回,可以通过这个实例拿到测量结果:
kotlin
fun Modifier.firstBaselineToTop(firstBaselineToTop: Dp) = this.then (
Modifier.layout { measurable, constraints ->
//测量结果会包装在Placeable实例中
val placeable: Placeable = measurable.measure(constraints)
...
}
)
现在Text组件本身的LayoutNode已经完成了测量,需要根据测量结果计算被修饰后的LayoutNode应占有的宽高并通过layout方法进行指定。在示例中我们期望的宽度就是文本宽度,而高度是指定的Text顶部到文本基线的高度与文本基线到Text底部的高度之和。为实现这个目标,我们能写出如下代码:
kotlin
fun Modifier.firstBaselineToTop(firstBaselineToTop: Dp) = this.then (
Modifier.layout { measurable, constraints ->
//采用布局约束对该组件完成测量,测量结果保存在Placeable实例中
val placeable: Placeable = measurable.measure(constraints)
//保证该组件是存在内容基线的
check(placeable[FirstBaseline] != AlignmentLine.Unspecified)
//获取基线的高度
val firstBaseline = placeable[FirstBaseline]
//应摆放的顶部高度为所设置的顶部到基线的高度减去实际组件内容顶部到基线的高度
val placeableY = firstBaselineToTop.roundToPx() - firstBaseline
//从组件占有的高度为应摆放的顶部高度加上实际内部的高度
val height = placeable.height + placeableY
//仅是高度发生了改变
layout(placeable.width, height) {
...
}
}
)
完成测量过程后,接下来是布局过程,可以在layout方法中使用placeable的placeRelative()方法指定原有应该绘制的内容在新的宽高下所应该摆放的相对位置,placeRelative方法会根据布局方向自动调整位置,比如阿拉伯国家一般更习惯于RTL这种布局方向。
在示例中,当前LayoutNode横坐标为0,而纵坐标则为Text组件顶部到文本顶部的距离,通过简单的数学计算就可以得到纵坐标了,完整代码如下:
kotlin
fun Modifier.firstBaselineToTop(firstBaselineToTop: Dp) = this.then(
Modifier.layout { measurable, constraints ->
//采用布局约束对该组件完成测量,测量结果保存在Placeable实例中
val placeable: Placeable = measurable.measure(constraints)
//保证该组件是存在内容基线的
check(placeable[FirstBaseline] != AlignmentLine.Unspecified)
//获取基线的高度
val firstBaseline = placeable[FirstBaseline]
//应摆放的顶部高度为所设置的顶部到基线的高度减去实际组件内容顶部到基线的高度
val placeableY = firstBaselineToTop.roundToPx() - firstBaseline
//从组件占有的高度为应摆放的顶部高度加上实际内部的高度
val height = placeable.height + placeableY
//仅是高度发生了改变,摆放位置
layout(placeable.width, height) {
placeable.placeRelative(0, placeableY)
}
})
用官方的api对比测试,效果一致
kotlin
@Composable
fun Greeting() {
Row {
Text(text = "Hello World", modifier = Modifier.firstBaselineToTop(30.dp))
Text(text = "Hello Kotlin", modifier = Modifier.paddingFromBaseline(top = 30.dp))
}
}
二、LayoutComposable
接下来说说LayoutComposable,前面的LayoutModifier可以类比于定制单元View。如果想在Compose中类似定制"ViewGroup",就需要使用LayoutComposable了。
kotlin
package androidx.compose.ui.layout
@Suppress("ComposableLambdaParameterPosition")
@UiComposable
@Composable inline fun Layout(
content: @Composable @UiComposable () -> Unit,
modifier: Modifier = Modifier,
measurePolicy: MeasurePolicy
) {...}
可以看到需要填写三个参数:modifier、content、measurePolicy。
- Modifier表示是由外部传入的修饰符,不难理解。
- content就是我们声明的子组件信息。
- measurePolicy表示测量策略,默认场景下只实现measure即可,如果还想实现固有特性测量,还需要重写Intrinsic系列方法。
接下来可以通过LayoutComposable自己实现一个Column,首先需要先声明这个Composable:
kotlin
@Composable
fun MyOwnColumn(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Layout(modifier = modifier, content = content) { measurables, constraints ->
...
}
}
和LayoutModifier一样,需要对所有子LayoutNode进行一次测量。牢记布局原理所提到的,每个LayoutNode只允许被测量一次。但与LayoutModifier不同的是,这里的measurables是一个List,而LayoutModifier中只是一个measurable对象。
在测量子LayoutNode时,也不做任何额外的限制,所有测量结果都存入placeables中:
kotlin
@Composable
fun MyOwnColumn(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Layout(modifier = modifier, content = content) { measurables, constraints ->
val placeables = measurables.map { measurable ->
//测量每个子组件
measurable.measure(constraints)
}
...
}
}
接下来仍然需要计算当前LayoutNode的宽高。这里的实现比较简单,将宽高直接设置为当前布局约束中的最大宽高,并仍然通过layout()方法指定,布局流程也与LayoutModifier完全相同。只需将每个子LayoutNode垂直堆叠起来即可:
kotlin
@Composable
fun MyOwnColumn(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Layout(modifier = modifier, content = content) { measurables, constraints ->
val placeables = measurables.map { measurable ->
//测量每个子组件
measurable.measure(constraints)
}
var positionY = 0
//布局
layout(constraints.maxWidth, constraints.maxHeight) {
placeables.forEach { placeable ->
placeable.placeRelative(x = 0, y = positionY)
positionY += placeable.height
}
}
}
}
通过测试,效果与Column组件一致:
kotlin
@Composable
fun Greeting() {
MyOwnColumn {
Text(text = "Hello World")
Text(text = "Hello Kotlin")
}
}
三、固有特性测量Intrinsic
前面我们提到了Compose布局原理,在Compose中的每个LayoutNode是不允许被多次进行测量的,多次测量在运行时会抛异常,但在很多场景中多次测量子UI组件是有意义的。假设有这样的需求场景,希望中间分割线与两边文案的一侧等高,如图所示:
为实现这个需求,假设可以预先测量得到两边文案组件的高度信息,取其中的最大值作为当前组件的高度,此时仅需将分割线高度值铺满整个父组件即可。
固有特性测量为我们提供了预先测量所有子组件来确定自身constraints(布局约束)的能力,并在正式测量阶段对子组件的测量产生影响。
1、使用内置组件的固有特性测量
使用固有特性测量的前提是组件需要适配固有特性测量,目前许多内置组件已经实现了固有特性测量,可以直接使用。还记得我们前面所提到的LayoutComposable组件吗,绝大多数内置组件都是用LayoutComposable实现的,LayoutComposable中需要传入一个measurePolicy,默认只需实现measure,但如果要实现固有特性测量,就需要额外重写Intrinsic系列方法。
在上面所提到的例子中,父组件所提供的能力使用基础组件中的Row组件即可承担,仅需为Row组件高度设置固有特性测量即可。使用Modifier.height(IntrinsicSize. Min)即可为高度设置固有特性测量。代码如下:
kotlin
@Composable
fun TwoTexts(modifier: Modifier = Modifier, text1: String, text2: String) {
Row(modifier = modifier.height(IntrinsicSize.Min)) { //这里使用了固有特性测量
Text(
modifier = Modifier
.weight(1f)
.padding(start = 4.dp)
.wrapContentWidth(Alignment.Start),
text = text1,
fontSize = 16.sp //字体大小不一样,高度不一样
)
Divider(color = Color.Black, modifier = Modifier.fillMaxHeight().width(1.dp)) //高度为父布局最大高度
Text(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
.wrapContentWidth(Alignment.End),
text = text2,
fontSize = 30.sp //字体大小不一样,高度不一样
)
}
}
//使用
Surface(border = BorderStroke(1.dp, Color.Blue) ) {
TwoTexts(text1 = "Hi,kotlin", text2 = "Hello,World")
}
效果如图所示
值得注意的是,此时仅使用Modifier.height(IntrinsicSize. Min)为高度设置了固有特性测量,宽度并没有进行设置,此时表示当宽度不限定时,根据子组件预先测量的宽高信息来计算父组件高度所允许的最小值。当然也可以设置宽度,也就表示当宽度受到限制时,根据子组件测量的宽高信息来计算父组件的宽度所允许的最小值。
我们只能对已经适配固有特性测量的内置组件使用IntrinsicSize. Min或IntrinsicSize. Max,否则程序运行时会crash。
2、自定义固有特性测量
在上面的例子中,使用Row组件的固有特性测量,预先测量子组件,并根据子组件的高度来确定Row组件的高度。然而其中具体是如何操作的,答案都藏在Row组件源码中。前面也提到如果想适配固有特性测量,需要额外重写measurePolicy中的固有特性测量Intrinsic系列方法。
打开MeasurePolicy的接口声明,我们看到Intrinsic系列方法共有四个,如下所示:
kotlin
@Stable
@JvmDefaultWithCompatibility
fun interface MeasurePolicy {
fun MeasureScope.measure(measurables: List<Measurable>,constraints: Constraints): MeasureResult
fun IntrinsicMeasureScope.minIntrinsicWidth(measurables: List<IntrinsicMeasurable>,height: Int): Int {
...
}
fun IntrinsicMeasureScope.minIntrinsicHeight(measurables: List<IntrinsicMeasurable>,width: Int): Int {
...
}
fun IntrinsicMeasureScope.maxIntrinsicWidth(measurables: List<IntrinsicMeasurable>,height: Int): Int {
...
}
fun IntrinsicMeasureScope.maxIntrinsicHeight(measurables: List<IntrinsicMeasurable>,width: Int): Int {
...
}
}
当使用Modifier.width(IntrinsicSize. Max)时,在测量阶段便会调用maxIntrinsicWidth方法,以此类推。
在使用固有特性测量前,需要确定对应Intrinsic方法是否重写,如果没有重写,则会crash。既然要实现Intrinsic方法,在Layout声明时就不能简单使用SAM转换了,需要规规矩矩实现MeasurePolicy接口,如下:
kotlin
@Composable
fun MyOwnColumn(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Layout(modifier = modifier, content = content, measurePolicy = object :MeasurePolicy{
override fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult {
TODO("Not yet implemented")
}
override fun IntrinsicMeasureScope.maxIntrinsicHeight(
measurables: List<IntrinsicMeasurable>,
width: Int
): Int {
TODO("Not yet implemented")
}
override fun IntrinsicMeasureScope.maxIntrinsicWidth(
measurables: List<IntrinsicMeasurable>,
height: Int
): Int {
TODO("Not yet implemented")
}
override fun IntrinsicMeasureScope.minIntrinsicHeight(
measurables: List<IntrinsicMeasurable>,
width: Int
): Int {
TODO("Not yet implemented")
}
override fun IntrinsicMeasureScope.minIntrinsicWidth(
measurables: List<IntrinsicMeasurable>,
height: Int
): Int {
TODO("Not yet implemented")
}
})
}
下面我们自定义Row组件实现与官方Row组件同样的固有特性效果。因为我们的需求场景只使用了Modifier.height(IntrinsicSize. Min),所以仅重写minIntrinsicHeight方法就可以了。
在重写的minIntrinsicHeight方法中,可以拿到子组件预先测量句柄intrinsicMeasurables。这个与前面提到的measurables用法完全相同。在预先测量所有子组件后,就可以根据子组件的高度计算其中的高度最大值,此值将会影响到正式测量时父组件获取到的constraints的高度信息。此时constraints中的maxHeight与minHeight都将被设置为返回的高度值,constraints中的高度为一个确定值:
kotlin
override fun IntrinsicMeasureScope.minIntrinsicHeight(
measurables: List<IntrinsicMeasurable>,
width: Int
): Int {
var maxHeight = 0
measurables.forEach {
//找出最大的高度并赋值给maxHeight
maxHeight = it.minIntrinsicHeight(width).coerceAtLeast(maxHeight)
}
return maxHeight
}
接着我们在测量中直接左右摆放,代码如下:
kotlin
override fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult {
val placeables = measurables.map { measurable ->
//给控件设置layoutId可以直接找到控件,自定义测量规则
//measurable.layoutId=="Divider"
measurable.measure(constraints)
}
var positionX = 0
return layout(constraints.maxWidth, constraints.maxHeight) {
placeables.forEach { placeable ->
placeable.placeRelative(positionX, 0)
positionX += placeable.width
}
}
}
接着使用我们的固有测量属性,完整代码如下:
kotlin
@Composable
fun Greeting() {
MyOwnRow(modifier = Modifier.height(IntrinsicSize.Min)) { //使用了自定义的固有测量属性
Text(
text = "Hello Kotlin",
fontSize = 10.sp //字体大小不一样,高度不一样
)
Divider(
color = Color.Black, modifier = Modifier
.fillMaxHeight()
.width(1.dp)
//.layoutId("Divider")
)
Text(
text = "Hello Android",
fontSize = 18.sp //字体大小不一样,高度不一样
)
}
}
@Composable
fun MyOwnRow(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Layout(modifier = modifier, content = content, measurePolicy = object : MeasurePolicy {
override fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult {
val placeables = measurables.map { measurable ->
//给控件设置layoutId可以直接找到控件,自定义测量规则
//measurable.layoutId=="Divider"
measurable.measure(constraints)
}
var positionX = 0
return layout(constraints.maxWidth, constraints.maxHeight) {
placeables.forEach { placeable ->
placeable.placeRelative(positionX, 0)
positionX += placeable.width
}
}
}
override fun IntrinsicMeasureScope.minIntrinsicHeight(
measurables: List<IntrinsicMeasurable>,
width: Int
): Int {
var maxHeight = 0
measurables.forEach {
//找出最大的高度并赋值给maxHeight
maxHeight = it.minIntrinsicHeight(width).coerceAtLeast(maxHeight)
}
return maxHeight
}
})
}
效果图如下所示
固有特性测量的本质就是允许父组件预先获取到每个子组件宽高信息后,影响自身在测量阶段获取到的constraints宽高信息,从而间接影响子组件的测量过程。在上面的例子中我们通过预先测量文案子组件的高度,从而确定了父组件在测量时获取到的constraints高度信息,并根据这个高度指定了分割线高度。
四、SubcomposeLayout
SubcomposeLayout允许子组件的组合阶段延迟到父组件的布局阶段进行,为我们提供了更强的测量定制能力。前面曾提到,固有特性测量的本质就是允许父组件预先获取到每个子组件宽高信息后,影响自身在测量阶段获取到的constraints宽高信息,从而间接影响子组件的测量过程。而利用SubcomposeLayout,可以做到将某个子组件的组合阶段延迟至其所依赖的同级子组件测量结束后进行,从而可以定制子组件间的组合、布局阶段顺序,以取代固有特性测量。
我们使用SubcomposeLayout实现上面固有特性中的例子。
在上面固有特性测量的例子中,可以先测量两侧文本的高度,而后为Divider指定高度,再进行测量。与固有特性测量不同的是,在整个过程中父组件是没有参与的。接下来看看SubcomposeLayout组件是如何使用的。
kotlin
@Composable
@UiComposable
fun SubcomposeLayout(
state: SubcomposeLayoutState,
modifier: Modifier = Modifier,
measurePolicy: SubcomposeMeasureScope.(Constraints) -> MeasureResult
) {...}
state
用于跟踪子组合的状态的对象,包括已分配的标签和测量信息。通过此参数,可以访问或修改子组合的状态。measurePolicy
一个 lambda 函数,定义了子组合的测量策略。此函数接收 SubcomposeMeasureScope 和 Constraints 作为参数,返回 MeasureResult 对象,表示子组合的测量结果。SubcomposeMeasureScope
提供一些用于测量的便捷函数和属性,而Constraints
描述了父布局对子组合的测量要求。
其实SubcomposeLayout和Layout组件是差不多的。不同的是,此时需要传入一个SubcomposeMeasureScope类型Lambda,打开接口声明可以看到其中仅有一个(名为subcompose)。
kotlin
interface SubcomposeMeasureScope : MeasureScope {
fun subcompose(slotId: Any?, content: @Composable () -> Unit): List<Measurable>
}
subcompose会根据传入的slotId和Composable生成一个LayoutNode用于构建子Composition,最终会返回所有子LayoutNode的Measurable测量句柄。其中Composable是我们声明的子组件信息。slotId是用来让SubcomposeLayout追踪管理我们所创建的子Composition的,作为唯一索引每个Composition都需要具有唯一的slotId,接下来看看如何在前面的示例场景中使用SubcomposeLayout。
实际上可以把所有待测量的组件分为文字组件和分隔符组件两部分。由于分隔符组件的高度是依赖文字组件的,所以声明分隔符组件时传入一个Int值作为测量高度。首先定义一个Composable。
kotlin
@Composable
fun SubcomposeRow(
modifier: Modifier,
text: @Composable () -> Unit,
divider: @Composable (Int) -> Unit//传入高度
) {
SubcomposeLayout(modifier = modifier) { constraints ->
...
}
}
首先可以使用subcompose来测量text中的所有LayoutNode,并根据测量结果计算出最大高度。
kotlin
@Composable
fun SubcomposeRow(
modifier: Modifier,
text: @Composable () -> Unit,
divider: @Composable (Int) -> Unit//传入高度
) {
SubcomposeLayout(modifier = modifier) { constraints ->
var maxHeight=0
var placeables = subcompose(slotId = "text",text).map {
val placeable = it.measure(constraints)
maxHeight = placeable.height.coerceAtLeast(maxHeight)
placeable
}
...
}
}
既然计算得到了文本的最大高度,接下来就可以将高度只传入分隔符组件中,完成组合阶段并进行测量。
kotlin
@Composable
fun SubcomposeRow(
modifier: Modifier,
text: @Composable () -> Unit,
divider: @Composable (Int) -> Unit//传入高度
) {
SubcomposeLayout(modifier = modifier) { constraints ->
var maxHeight=0
var placeables = subcompose(slotId = "text",text).map {
val placeable = it.measure(constraints)
maxHeight = placeable.height.coerceAtLeast(maxHeight)
placeable
}
val dividerPlaceable = subcompose(slotId ="divider"){
divider(maxHeight)
}.map {
it.measure(constraints.copy(minWidth = 0))
}
...
}
}
与前面固有特性测量中的一样,在测量Divider组件时,仍需重新复制一份constraints并将其minWidth设置为0,如果不修改,Divider组件宽度默认会与整个组件宽度相同。接下来分别对文字组件和分隔符组件进行布局。
kotlin
@Composable
fun SubcomposeRow(
modifier: Modifier,
text: @Composable () -> Unit,
divider: @Composable (Int) -> Unit//传入高度
) {
SubcomposeLayout(modifier = modifier) { constraints ->
var maxHeight = 0
var placeables = subcompose(slotId = "text", text).map {
val placeable = it.measure(constraints)
maxHeight = placeable.height.coerceAtLeast(maxHeight)
placeable
}
// divider设置文本最大的高度
val dividerPlaceable = subcompose(slotId = "divider") {
divider(maxHeight)
}.map {
it.measure(constraints.copy(minWidth = 0))
}
//布局
layout(constraints.maxWidth, constraints.maxHeight) {
placeables.forEach {
it.placeRelative(0, 0)
}
val midPos = constraints.maxWidth / 2
dividerPlaceable.forEach {
it.placeRelative(midPos, 0)
}
}
}
}
SubcomposeRow控件的使用
kotlin
@Composable
fun Greeting() {
SubcomposeRow(modifier = Modifier.fillMaxWidth(), text = {
Text(
text = "Hello Kotlin",
fontSize = 14.sp, //字体大小不一样,高度不一样
modifier = Modifier.wrapContentWidth(Alignment.Start)
)
Text(
text = "Hello Android",
fontSize = 18.sp, //字体大小不一样,高度不一样
modifier = Modifier.wrapContentWidth(Alignment.End)
)
}) { heightPx -> //使用高度
//px转dp
val heightDp = with(LocalDensity.current) {
heightPx.toDp()
}
Divider(
color = Color.Black, modifier = Modifier
.height(heightDp) //使用高度
.width(1.dp)
)
}
}
UI效果
SubcomposeLayout具有更强的灵活性,然而性能上不如常规Layout,因为子组件的组合阶段需要延迟到父组件布局阶段才能进行,因此还需要额外创建一个子Composition,因此SubcomposeLayout可能并不适用在一些对性能要求比较高的UI部分。
参考内容
本文为学习博客,内容来自书籍《Jetpack Compose 从入门到实战》