聊一聊Compose的固有特性测量Intrinsic

4,381 阅读8分钟

这是我参与更文挑战的第1天,活动详情查看: 更文挑战

目前有一个正在进行的 Jetpack Compose中文手册 项目,旨在帮助开发者更好的理解和掌握Compose框架,目前仍还在开荒中,欢迎大家进行关注与加入! 这篇文章由本人撰写,目前已经发布到该手册中,欢迎进行查阅。

前言

由于官方文档对固有特性测量描述的模糊不清,所提供的案例中只说明了在案例特定场景下可以使用固有特性测量,却没有说清固有特性测量到底做了什么,导致开发者不能正确理解其本质,从而无法为其进行拓展。

在深入了解固有特性测量的本质后,发现这个概念其实很简单,只是官方惜字如金没有说清。为帮助国内开发者理解固有特性测量的本质,我决定写下这篇文章,通过在自定义Layout中适配固有特性测量的需求从而揭露固有特性测量的本质。

固有特性测量是什么

使用Jetpack Compose完成你的自定义Layout 一文中我们提到Compose布局原理,Compose中的每个UI组件是不允许多次进行测量的,多次测量在运行时会抛异常,禁止多次测量的好处是为了提高性能,但在很多场景中多次测量子UI组件是有意义的。在Jetpack Compose代码实验室中就提供了这样一种场景,我们希望中间分割线高度与两边文案高的一边保持相等。

image.png 为实现这个需求,官方所提供的设计方案是希望父组件可以预先获取到两边的文案组件高度信息,然后计算两边高度的最大值即可确定当前父组件的高度值,此时仅需将分割线高度值铺满整个父组件即可。

为了实现父组件预先获取文案组件高度信息从而确定自身的高度信息,Compose为开发者们提供了固有特性测量机制,允许开发者在每个子组件正式测量前能获取到每个子组件的宽高等信息。

在基础组件中使用固有特性测量

使用固有特性测量的前提是当前作用的Layout需要适配固有特性测量,目前许多基础组件已经完成对固有特性测量的适配,可以直接使用。

在上面所提到的例子中父组件所提供的能力使用基础组件中的Row组件即可承担,我们仅需为Row组件高度设置固有特性测量即可。我们使用 Modifier.height(IntrinsicSize.Min) 即可为高度设置固有特性测量。

@Composable
fun TwoTexts(modifier: Modifier = Modifier, text1: String, text2: String) {
    Row(modifier = modifier.height(IntrinsicSize.Min)) { // I'm here
        Text(
            modifier = Modifier
                .weight(1f)
                .padding(start = 4.dp)
                .wrapContentWidth(Alignment.Start),
            text = text1
        )

        Divider(color = Color.Black, modifier = Modifier.fillMaxHeight().width(1.dp))
        Text(
            modifier = Modifier
                .weight(1f)
                .padding(end = 4.dp)
                .wrapContentWidth(Alignment.End),
            text = text2
        )
    }
}

@Preview
@Composable
fun TwoTextsPreview() {
    LayoutsCodelabTheme {
        Surface {
            TwoTexts(text1 = "Hi", text2 = "there")
        }
    }
}

通过使用固有特性测量即可完成上面所述场景的需求,展示效果如图所示。

image.png 值得注意的是此时我们的Modifier仅使用 Modifier.height(IntrinsicSize.Min) 为高度设置了固有特性测量,并没有进行宽度的设置。此时所表达的意思是,当宽度不限时通过子组件预先测量的宽高信息所能计算的高度最少可以是多少。当然你也可以进行宽度的设置,当宽度受限时通过子组件预先测量的宽高信息所能计算的高度最少可以是多少。

可能你不能理解宽度受限可能影响高度这件事,其实我们常用的Text组件当宽度收到不同限制时,其高度就是不一样的。

Column(Modifier.fillMaxSize()) {
    Box(Modifier.width(50.dp).background(Color.Red)) {
        Text(text = "Jetpack Compose is an excellent development tool")
    }
    Box(Modifier.width(100.dp).background(Color.Yellow)) {
        Text(text = "Jetpack Compose is an excellent development tool")
    }
}

image.png

⚠️ 注意事项: 你只能对已经适配固有特性测量能力的组件使用 IntrinsicSize.MinIntrinsicSize.Max ,否则程序会运行时抛出异常而崩溃。对于所有自定义Layout的开发者来说如果支持使用者使用固有特性测量,则必须要进行固有特性测量的适配工作。

为自定义Layout适配固有特性测量

从上面的例子可以发现,我们仅使用 Modifier.height(IntrinsicSize.Min) 即可交给Row组件根据子组件的信息进行计算从而确定一个固定的高度。然而之前他时如何操作的,对于开发者而言是完全未知的。所以本文将继续深入下去,通过一个自定义Layout适配固有特性测量的过程来摸清固有特性测量的整个流程。

重写MeasurePolicy固有特性测量相关方法

对于适配固有特性测量的Layout,我们需要对MeasurePolicy下的固有特性测量方法进行重写。还记得MeasurePolicy是谁嘛?没错他就是我们在自定义Layout中传入的最后的lambda SAM转换的类型。

@Composable inline fun Layout(
    content: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    measurePolicy: MeasurePolicy
) 

对于固有特性测量的适配,我们需要根据需求重写以下四个方法。

image.png

使用 Modifier.width(IntrinsicSize.Max) ,则会调用 maxIntrinsicWidth 方法

使用 Modifier.width(IntrinsicSize.Min) ,则会调用 minIntrinsicWidth 方法

使用 Modifier.height(IntrinsicSize.Max) ,则会调用 maxIntrinsicHeight 方法

使用 Modifier.height(IntrinsicSize.Min) ,则会调用 minIntrinsicHeight 方法

⚠️ 注意事项: 如果哪个Modifier使用了, 但其对应方法没有重写仍会崩溃。

在Layout声明时,我们就不能使用SAM形式了,而是要规规矩矩实现MeasurePolicy

@Composable
fun IntrinsicRow(modifier: Modifier, content: @Composable () -> Unit){
    Layout(
        content = content,
        modifier = modifier,
        measurePolicy = object: MeasurePolicy {
            override fun MeasureScope.measure(
                measurables: List<Measurable>,
                constraints: Constraints
            ): MeasureResult {
                TODO("Not yet implemented")
            }

            override fun IntrinsicMeasureScope.minIntrinsicHeight(
                measurables: List<IntrinsicMeasurable>,
                width: Int
            ): Int {
                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.minIntrinsicWidth(
                measurables: List<IntrinsicMeasurable>,
                height: Int
            ): Int {
                TODO("Not yet implemented")
            }
        }
    ) 
}

在我们的案例中仅使用了Modifier.height(IntrinsicSize.Min) ,出于简单考虑仅重写了minIntrinsicHeight 以作示例。

minIntrinsicHeightmaxIntrinsicHeight有相同的两个参数 measurableswidth

measurables:类似于measure 方法的measurables,用于获取子组件的宽高信息。

width:父组件所能提供的最大宽度(无论此时是minIntrinsicHeight 还是 maxIntrinsicHeight

Modifier
    .widthIn(100.dp, 200.dp) //在此场景下minIntrinsicHeight的参数width值为200.dp对应的px
    .height(IntrinsicSize.Max)

接下来我们使用 maxIntrinsicHeight 即可获取到每个子组件在给定宽度下能够保证正确展示的最小高度,这个正确展示的高度是由子组件来保证的。再得到所有子组件的高度信息后,我们即可计算最大高度值,此值将会被设置为当前父组件(也就是当前自定义Layout)的固定高度。

override fun IntrinsicMeasureScope.minIntrinsicHeight(
    measurables: List<IntrinsicMeasurable>,
    width: Int
): Int {
    var maxHeight = 0
    measurables.forEach {
        maxHeight = it.minIntrinsicHeight(width).coerceAtLeast(maxHeight)
    }
    return maxHeight
}

在Layout measure中适配

接下来我们将所有使用的Composable声明出来。

IntrinsicRow(
    modifier = Modifier
        .fillMaxWidth()
        .height(IntrinsicSize.Min)
) {
    Text(text = "Left", Modifier.wrapContentWidth(Alignment.Start).layoutId("main"))
    Divider(
        color = Color.Black,
        modifier = Modifier
            .width(4.dp)
            .fillMaxHeight()
      			.layoutId("devider")
    )
    Text(text = "Right", Modifier.wrapContentWidth(Alignment.End).layoutId("main"))
}

此时,由于声明了Modifier.fillMaxWidth(),导致我们自定义Layout宽度是固定的,又因为我们使用了固有特性测量,此时我们自定义Layout的高度也是固定的。具体表现为constraints参数中minWidth与maxWidth相等(宽度固定),minHeight与maxHeight相等(高度固定)。

而我们希望Devider测量的宽度不应是固定与父组件相同,而是要根据其自身声明的宽度,也就是 Modifier.width(4.dp) ,所以我们对Devider测量使用的constraints进行了修改。将其最小值设置为零。

@Composable
fun IntrinsicRow(modifier: Modifier, content: @Composable () -> Unit){
    Layout(
        content = content,
        modifier = modifier,
        measurePolicy = object: MeasurePolicy {
            override fun MeasureScope.measure(
                measurables: List<Measurable>,
                constraints: Constraints
            ): MeasureResult {
                var devideConstraints = constraints.copy(minWidth = 0)
                var mainPlaceables = measurables.filter {
                    it.layoutId == "main"
                }.map {
                    it.measure(constraints)
                }
                var devidePlaceable = measurables.first { it.layoutId == "devider"}.measure(devideConstraints)
                var midPos = constraints.maxWidth / 2
                return layout(constraints.maxWidth, constraints.maxHeight) {
                    mainPlaceables.forEach {
                        it.placeRelative(0, 0)
                    }
                    devidePlaceable.placeRelative(midPos, 0)
                }
            }

            override fun IntrinsicMeasureScope.minIntrinsicHeight(
                measurables: List<IntrinsicMeasurable>,
                width: Int
            ): Int {
                var maxHeight = 0
                measurables.forEach {
                    maxHeight = it.maxIntrinsicHeight(width).coerceAtLeast(maxHeight)
                }
                return maxHeight
            }
        }
    )
}

最终效果如下,这样我们就为我们自定义Layout适配了固有特性测量能力。

image.png

对固有特性测量的思考

固有特性测量的本质就是父组件可在正式测量布局前预先获取到每个子组件宽高信息后通过计算来确定自身的固定宽度或高度,从而间接影响到其中包含的部分子组件布局信息。也就是说子组件可以根据自身宽高信息从而确定父组件的宽度或高度,从而影响其他子组件布局。在我们使用的方案中,我们通过文案子组件的高度确定了父组件的固定高度,从而间接确定了分割线的高度。此时子组件要通过固有特性测量这种方式,通过父组件而对其他子组件产生影响,然而在有些场景下我们不希望父组件参与其中,而希望子组件间通过测量的先后顺序直接相互影响,Compose为我们提供了SubcomposeLayout来处理这类子组件存在依赖关系的场景。有关于SubcomposeLayout内容后续会更新,请持续关注。