利用 JetPack Compose 手写一个自定义布局

Posted 清风Coolbreeze

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了利用 JetPack Compose 手写一个自定义布局相关的知识,希望对你有一定的参考价值。

上一篇介绍了JetPack Compse 基础布局的用法,Compose 提供了许多内置布局给我们使用,但是再多也不一定满足产品的需求。所以我们需要学习如何自定义布局。

如何学习自定义布局,那就“照虎画猫”看 JetPack Compose 内置布局源码模仿一个 。本篇就照着 JetPack Compose 的 Row 布局 ,实现一个自己的 Row 布局。

一般自定义布局就是调用Layout 函数,Box、Row、Column 布局也是调用了这个函数,下面看看这个 Layout 函数参数吧。

/**
 * @param content 布局的 children 
 * @param modifier 布局的修饰符
 * @param measurePolicy 布局策略
*/
@Composable inline fun Layout(
    content: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    measurePolicy: MeasurePolicy
) 

自定义布局最关键的就是 measurePolicy 这个参数,children 的测量和在布局中位置都是它决定的。MeasurePolicy 是一个函数式接口 (kotlin 1.4 特性)。

fun interface MeasurePolicy {
    //进行 children 的测量及在布局的位置
     fun MeasureScope.measure(
        measurables: List<Measurable>,//要测量和放置的 children
        constraints: Constraints //布局的约束
    ): MeasureResult
    ……
}

写成 函数式接口的好处就是,我们写代码的时候可以简写

//如果是普通的 interface 我们需要这样写
val measurePolicy = object : MeasurePolicy {
        override fun MeasureScope.measure(
            measurables: List<Measurable>,
            constraints: Constraints
        ): MeasureResult { TODO() }
    }
// 函数式接口 支持 SAM 转换,所有上面可以简写成下面形式
  val measurePolicy = MeasurePolicy { measurables, constraints -> TODO() }

下面的例子中我就是用简写的形式了,你要记住 lambda块 就是原始接口的 measure 方法。

自定义布局怎么写?下面就打算仿 Row 布局,写一个属于自己的 Row 布局 通过仿写来学习一下如何自定义布局。我们打算写4个版本的 Row ,循环渐进的去写 Row

自定义布局——手写一个 Row 布局

Row V1 版本

此版本只简单实现 children 水平方向依次排列的功能。

/**
 * 自定义Layout Row v1.0
 *  实现简单水平排列
 */
@Composable
private fun MyRowV1(modifier: Modifier = Modifier, content: @Composable () -> Unit) {

    //布局的测量策略
    val measurePolicy = MeasurePolicy { measurables, constraints ->
        //0️⃣ 测量 children     
        val placeables = measurables.map { child ->
            //1️⃣ 测量每一个,每个child只能测量一次,不能多次测量 child 控制 child 宽高在约束constraints范围内
            child.measure(constraints)
        }
        //记录下一个要放置的child 的x值
        var xPosition = 0

        // 2️⃣ 参考测量结果,决定布局的大小,这里没有处理直接用constraints的最大值
        layout(constraints.minWidth, constraints.minHeight) {/*this:Placeable.PlacementScope*/
            // 3️⃣  放置children
            placeables.forEach { placeable ->
                //放置每一个 child
                //4️⃣ placeRelative 此方法必须在Placeable.PlacementScope 作用域下调用
                placeable.placeRelative(xPosition, 0)
                //按水平方向依次排列
                xPosition += placeable.width
            }
        }
    }
    //把对应的参数放到Layout函数中
    Layout(content = content, modifier = modifier, measurePolicy = measurePolicy)
}

看上面代码总结自定义布局大概有三步

  1. 测量 children。
  2. 决定布局的大小。
  3. 将 children 放置到布局上。

⚠️ 在 Compose 中每一个 child 只能测量一次。

MyRowV1 就是我通过自定义布局实现的低配版 Row 下面看看使用效果怎么样吧。

MyRowV1(Modifier.width(200.dp).height(100.dp)) { RowChildren() }

@Composable
private fun RowChildren() {
    Box(
        modifier = Modifier
            .size(100.dp)
            .background(Blue)
    )
    Box(
        modifier = Modifier
            .size(100.dp)
            .background(Red)
    )
}

👆 代码1-1

看上去没啥问题,children 确实按照水平排列了。 但实际上蓝色Box 和红色Box 的宽度都是200.dp,MyRowV1 的每一个 child 的宽度和 MyRowV1 自己设置的宽度一样大。这是为什么呢?child 自己设置宽度是100.dp 为啥变成了200.dp呢?咋还不听话了呢 🙉 ?

下面来分析一下 child 的大小 是通过测量得到的,也就是 child.measure(constraints) 。child 通过 measure 方法 获取自身的大小,measure 只有一个参数 Constraints,目前看来导致上面问题的应该就是这个 Constraints 导致的吧。 和视图大小的相关的 Constraints 源码大致如下,有4 变量,最大/小宽度,最大/小高度。

// 简化版 非 Constraints 源码全部
class Constraints{
    val minWidth: Int ,
    val maxWidth: Int ,
    val minHeight: Int,
    val maxHeight: Int
 }

这里不做源码分析,以后有时间单独写一篇,这里就先用一段伪代码来模拟 measure 源码大致过程来分析

fun main() {
    //假设MeasurePolicy中 measure的参数constraints实参如下 MeasurePolicy { measurables, constraints ->
    val constraints = Constraints(minWidth = 200, maxWidth = 200, minHeight = 100, maxHeight = 100)
    //child.measure(constraints) 测量child
    measure(constraints)
}

//模拟child 的测量方法
fun measure(measurementConstraints: Constraints) {
    // child 自己通过Modifier设置的大小,对应的的Constraints
    val childConstraints = Constraints(minWidth = 100, maxWidth = 100, minHeight = 100, maxHeight = 100)
    //1.自己的约束和父容器约束按 constrain 函数 进行合并,得到新的 constraints
    val constraints = measurementConstraints.constrain(childConstraints)

    //---{假设child 它没有 children 了,否则它还要重复上面操作,继续测量他的孩子}----

    //2.child 依据新的 constraints,汇报给父布局一个大小值。
    val intSize = IntSize(constraints.minWidth, constraints.minHeight)
    //3.更加原来测量的Constraints再次核实一下 child 是否是按照它的约束来的,防止child汇报有问题,防止child不听话,瞎搞
    // coerceIn 是 kotlin.ranges包下的一个方法
    val width = intSize.width.coerceIn(measurementConstraints.minWidth, measurementConstraints.maxWidth)
    val height = intSize.height.coerceIn(measurementConstraints.minHeight, measurementConstraints.maxHeight)
    println("width=$width,height=$height")
}

/**
 * kotlin.ranges包下的一个方法,保证调用者得到的返回值在[minimumValue,maximumValue] 范围内
 */
public fun Int.coerceIn(minimumValue: Int, maximumValue: Int): Int {
    if (minimumValue > maximumValue) throw IllegalArgumentException("Cannot coerce value to an empty range: maximum $maximumValue is less than minimum $minimumValue.")
    if (this < minimumValue) return minimumValue
    if (this > maximumValue) return maximumValue
    return this
}
// 和 Int.coerceIn的作用类似,保证返回值在调用者的返回内
fun Constraints.constrain(otherConstraints: Constraints) = Constraints(
    minWidth = otherConstraints.minWidth.coerceIn(minWidth, maxWidth),
    maxWidth = otherConstraints.maxWidth.coerceIn(minWidth, maxWidth),
    minHeight = otherConstraints.minHeight.coerceIn(minHeight, maxHeight),
    maxHeight = otherConstraints.maxHeight.coerceIn(minHeight, maxHeight)
)

--------输出-----------
 width=200,height=100

👆 代码1-2 测量的大致流程 1 child 测量时的 constraints 会和 child 本身 constraints 进行 constrain 函数计算,得到新的 Constraints 2.child 依据新的Constraints向父容器汇报自己的大小 3.再结合当时测量是的约束决定 child 最终的大小。

上面代码大致的意思是:child 不管你自己想要多大,你都要在我给你的测量约束范围内。

一般情况 MeasurePolicy 的measure 的参数 Constraints 和通过 Modifier 设置的大小是对应的 (其他情况暂不考虑)。例如 上面例子中 给 MyRowV1 Modifier.width(200.dp).height(100.dp) 那么 此时 measure 参数 constraints 实参应该就是 Constraints(minWidth = 200.dp, maxWidth = ``200.dp``, minHeight = ``100.dp``, maxHeight = ``100.dp``) 我们拿着这个约束去测量 child,结合上面的分析,child 测量后的大小就是 width=200,height=100,所以蓝色Box 和红色Box 的宽度都是200.dp。

Row V2 版本

根据V1 版本末尾的分析,我们知道影响 child 大小的是测量时的约束,我要解决V1版本的问题,就是修改测量child 时的 Constraints。于是Row V2 版本的代码如下

/**
 * 自定义Layout Row v2.0
 * 扩大测量约束范围,让children测量后的宽高尽可能和自己设置的大小一致
 */
@Composable
private fun MyRowV2(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
    val measurePolicy = MeasurePolicy { measurables, constraints ->
        val placeables = measurables.map { child ->
            // 0️⃣  仅此行代码与V1版本不同
            child.measure(Constraints(0, constraints.maxWidth, 0, constraints.maxHeight))
        }
        //放置的逻辑不变
        var xPosition = 0
        layout(constraints.minWidth, constraints.minHeight) {
            placeables.forEach { placeable ->
                placeable.placeRelative(xPosition, 0)
                xPosition += placeable.width
            }
        }
    }
    Layout(content = content, modifier = modifier, measurePolicy = measurePolicy)
}

我们在 代码1-2 得到了一个结论:child 不管你自己想要多大,你都要在我给你的测量约束范围内。上面的例子中 child 自己设置的大小是[width=100,height=100]。

对应V1 版本来说测量是的Constraints(minWidth = 200, maxWidth = 200, minHeight = 100, maxHeight = 100),很显然测量是约束最小宽度都是200,所以child 想让自己宽度变成100 那是不可能的,因此 child 的宽度变成了200。 但 V2 版本,我们把测量 Constraints 最小值到改成了0,最大值不变,扩大的约束范围,此时测量的约束Constraints(minWidth = 0, maxWidth = 200, minHeight = 0, maxHeight = 100),这个时候 宽度最小值是0,最大值是200,child 想让自己宽度是100,当然是可以的,因为100在[0,200] 之间。

对应V2版本来说,children 及可以按照自己设置的大小显示了。(child 大小于等于 父容器大小的情况)

V2版本还有个问题就是,child 这样依次排列,有可能超出父布局

// children的总宽度是200.dp,我们指定MyRowV2 的宽度是160.dp,children 超出父容器的部分仍会显示
MyRowV2(Modifier.width(160.dp).height(100.dp)) { RowChildren() /*RowChildren() 此函数代码和代码1-1中的一样*/}

Row V3 版本

修改 children 超出父容器的问题

/**
 * 自定义Layout Row v3.0
 * 解决 child 大小超过父容器大小的问题
 */
@Composable
private fun MyRowV3(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
    val measurePolicy = MeasurePolicy { measurables, constraints ->
        // 记录children 已经占据的空间
        var childrenSpace = 0
        val placeables = measurables.mapIndexed { index, child ->
            val placeable = child.measure(
                Constraints(
                    0,
                    constraints.maxWidth - childrenSpace, //使用剩余空间去测量
                    0,
                    constraints.maxHeight
                )
            )
            // 每次测量后更新 children 占据的空间
            childrenSpace += placeable.width
            placeable
        }

        //放置的逻辑不变
        var xPosition = 0
        layout(constraints.minWidth, constraints.minHeight) {
            placeables.forEach { placeable ->
                // 放置每一个 child
                placeable.placeRelative(xPosition, 0)
                xPosition += placeable.width
            }
        }
    }
    Layout(content = content, modifier = modifier, measurePolicy = measurePolicy)
}

在V1、V2 版本,每一个 child 的测量约束都是一样的,但实际上对于Row 这种布局来说,当我们测量完一个 child 后,下一个 child 应该从剩下的空间去分配大小。所以对于V3 版本,我们定义了一个 childrenSpace 变量,记录children 已经占据的空间(对于Row 来说,我们只需要考虑 x 轴方向,也就是宽度)。每次测量时的约束,最大宽度应该是父容器剩余的宽度(父容器最大宽度-childrenSpace),这样测量出来的 child 就不会超过 父容器总宽度了,每测量完一个 child 之后 更新一下 childrenSpace 值。

Row V4 版本

这个版本在上一版的基础上,加上 weight 功能,写这个功能主要是学习如何让 child 在测量的时候,告诉父容器一些额外信息。

在上面的版本中,我们都是遍历 measurables: ``List``<``Measurable``> 然后调用 Measurable 的measure 方法去测量 child 的大小,除了这个方法,它还有个属性 parentData,我们就可以把告诉父容器的额外信息放在这里面。

  val placeables = measurables.map { child:Measurable ->
            //取额外信息
            child.parentData
            child.measure(constraints)
        }

我们没有办法直接修改Measurable中的 parentData 属性, 需要通过设置 Modifier 的方式修改。ParentDataModifier 就是负责修改 parentData 的,我们写一个类实现 ParentDataModifier 接口中的 modifyParentData 方法。在modifyParentData 方法中写修改数据逻辑。

data class MyRowWeightModifier(val weight: Float/*要设置的权重*/) : ParentDataModifier {
    // 修改parentData的值
    override fun Density.modifyParentData(parentData: Any?): Any {
        // 如果参数类型正确且不为空,就修改一下直接返回,否则创建一个新的对象,修改后再返回
        var data = parentData as? MyRowParentData?
        if (data == null) {
            data = MyRowParentData()
        }
        data.weight = weight
        return data
    }
}

// 用于保存ParentData数据模型
data class MyRowParentData(var weight: Float = 0f)

接着我们定义一个作用域,在作用域中写一个写一个Modifier 扩展函数,用于创建 MyRowWeightModifier

interface MyRowScope {
    //then 是 Modifier 的一个方法,用于形成 Modifier 链
    fun Modifier.weight(weight: Float) = this.then(MyRowWeightModifier(weight))

    // 创建一个单例
    companion object : MyRowScope
}

之所以把 weight 方法写在一个接口中,是因为我们想限定调用范围,我们只想让 weight 方法之和我们自定义的MyRow 布局中调用。这个和我们之前说的 Box 的 child 设置 align 是如出一辙的。 根据上面的铺垫,的不点下面是 MyRowV4的完整

/**
 * 自定义Layout Row v4.0
 *
 * 带 weight 功能的 Row
 */
@Composable
private fun MyRowV4(modifier: Modifier = Modifier, content: @Composable MyRowScope.() -> Unit /*这样content函数作用域就是MyRowScope了*/) {
    val measurePolicy = MeasurePolicy { measurables, constraints ->
        // 存放测量后的 child 信息
        val placeables = arrayOfNulls<Placeable>(measurables.size)
        // 通过该parentData 就可以获取到我们设置的MyRowParentData数据了
        val parentDatas =
            Array(measurables.size) { measurables[it].parentData as? MyRowParentData }
        // 记录children 已经占据的空间
        var childrenSpace = 0
        // 所有权重之和
        var totalWeight = 0f
        // 设置了weight的child个数
        var weightChildrenCount = 0
        measurables.fastForEachIndexed { index, child ->
            val weight = parentDatas[index]?.weight
            if (weight != null) { // 有weight的child 记录一下
                totalWeight += weight
                weightChildrenCount++
            } else { // 没有weight的child直接测量
                val placeable = child.measure(
                    Constraints(
                        0,
                        constraints.maxWidth - childrenSpace, // 限制child在剩余空间范围内
                        0,
                        constraints.maxHeight
                    )
                )
                // 每次测量后更新 children 占据的空间
                childrenSpace += placeable.width
                placeables[index] = placeable
            }
        }
        // 把剩下的空间平均分布
        val weightUnitSpace =
            if (totalWeight > 0) (constraints.maxWidth - childrenSpace) / totalWeight else 0f
        measurables.fastForEachIndexed { index, child ->
            val weight = parentDatas[index]?.weight
            if (weight != null) {
                // 根据 child 的 weight 分配空间
                val distributionSpace = (weightUnitSpace * weight).roundToInt()
                val placeable = child.measure(
                    Constraints(
                        distributionSpace,
                        distributionSpace,
                        0,
                        constraints.maxHeight
                    )
                )
                placeables[index] = placeable
            }
        }

        var xPosition = 0
        layout(constraints.minWidth, constraints.minHeight) {
            placeables.forEach { placeable ->
                if (placeable == null) {
                    return@layout
                }
                // 放置每一个 child
                placeable.placeRelative(xPosition, 0)
                xPosition += placeable.width
            }
        }
    }

    Layout(content = { MyRowScope.content() }, modifier = modifier, measurePolicy = measurePolicy)
}

使用

MyRowV4(Modifier.size(300.dp, 100.dp)) {
                Box(
                    modifier = Modifier
                        .size(100.dp)
                        .background(Blue)
                )
                Box(
                    modifier = Modifier
                        .height(100.dp)
                        .weight(4f)
                        .background(Red)
                )
                Box(
                    modifier = Modifier
                        .height(100.dp)
                        .weight(1f)
                        .background(Green)
                )

            }

效果

总结

通过仿写 Row 布局,学习了JetPack Compose 如何自定义布局

  • 在自定义 Row V1版本中我们学习了自定义的布局的简单步骤
  • 在自定义布局 Row V2 和 V3 中,知道了影响 children 大小的因素,如何去修正child 测量的大小。
  • 在自定义布局 Row V4 版本 学习了 child 是如果修改 parentData,告诉父容器一些额外数据的。

以上是关于利用 JetPack Compose 手写一个自定义布局的主要内容,如果未能解决你的问题,请参考以下文章

Jetpack Composes 01

Android-利用Jetpack-Compose-+Paging3+swiperefresh实现分页加载,下拉上拉效果

保姆级Jetpack Compose入门篇,含视频教程源码

Jetpack Compose入门篇-简约而不简单

谷歌内部学习分享 Android Jetpack Compose开发应用指南,赶紧码住

谷歌内部学习分享 Android Jetpack Compose开发应用指南,赶紧码住