利用 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)
}
看上面代码总结自定义布局大概有三步
- 测量 children。
- 决定布局的大小。
- 将 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 手写一个自定义布局的主要内容,如果未能解决你的问题,请参考以下文章
Android-利用Jetpack-Compose-+Paging3+swiperefresh实现分页加载,下拉上拉效果