Jetpack Compose 中绘制流程的三个阶段
基本概念
与大多数其他界面工具包一样,Compose
会通过几个不同的“阶段”来渲染帧。如果我们观察一下 Android View
系统,就会发现它有 3 个主要阶段:测量、布局和绘制。Compose
和它非常相似,但开头多了一个叫做“组合”的重要阶段。
Compose 有 3 个主要的阶段:
- 组合:要显示什么样的界面。Compose 运行Composable可组合函数并创建LayoutNode视图树。
- 布局:要放置界面的位置。该阶段包含两个步骤:测量和放置。对于视图树中的每个LayoutNode节点进行宽高尺寸测量并完成位置摆放,布局元素都会根据 2D 坐标来测量并放置自己及其所有子元素。
- 绘制:渲染的方式。将所有LayoutNode界面元素会绘制到画布(通常是设备屏幕)之上。
这些阶段的顺序通常是相同的,从而让数据能够沿一个方向(从组合到布局,再到绘制)生成帧(也称为单向数据流)。
您可以放心地假设每个帧都会以虚拟方式经历这 3 个阶段,但为了保障性能,Compose 会避免在所有这些阶段中重复执行根据相同输入计算出相同结果的工作。如果可以重复使用前面计算出的结果,Compose 会跳过对应的可组合函数;如果没有必要,Compose 界面不会对整个树进行重新布局或重新绘制。Compose 只会执行更新界面所需的最低限度的工作。之所以能够实现这种优化,是因为 Compose 会跟踪不同阶段中的状态读取。
所谓状态读取就是通常使用 mutableStateOf()
创建的,然后通过以下两种方式之一进行访问:
- val state = remember { mutableStateOf() }, 然后访问
state.value
属性值
// State read without property delegate.
val paddingState: MutableState<Dp> = remember {
mutableStateOf(8.dp) }
Text(
text = "Hello",
modifier = Modifier.padding(paddingState.value)
)
- val state by remember { mutableStateOf() } 使用 Kotlin 属性委托
by
语法, 直接使用state
值
// State read with property delegate.
var padding: Dp by remember {
mutableStateOf(8.dp) }
Text(
text = "Hello",
modifier = Modifier.padding(padding)
)
当您在上述任一阶段中读取快照状态值时,Compose 会自动跟踪在系统读取该值时正在执行的操作。通过这项跟踪,Compose 能够在状态值发生更改时重新执行读取程序;Compose 以这项跟踪为基础实现了对状态的观察。
第 1 阶段:组合
组合阶段的主要目标是生成并维护LayoutNode视图树,在Activity中执行setContent时,会开始首次组合,这会执行所有的Composable函数生成与之对应的LayoutNode视图树。而后当Composable依赖的状态值发生更改时,Recomposer
会安排重新运行所有要读取相应状态值的可组合函数,即所谓的重组。
- 当前组件发生重组时,子Composable被依次重新调用,被调用的子Composable 会将当前传入的参数和之前重组中的参数进行比较,若参数变化,则Composable发生重组,更新LayoutNode视图树上的节点,UI发生更新。若参数无变化,则跳过本次执行,即所谓的智能重组,LayoutNode视图树中对应的节点不变,UI无变化。
- 当前组件发生重组时,如果子Composable在重组中没有被调用到,其对应节点及其子节点会从LayoutNode视图树中被删除,UI会从屏幕移除。
根据组合结果,Compose 界面会运行布局和绘制阶段。请注意,如果输入未更改,运行时可能会决定跳过部分或全部可组合函数。如果内容保持不变,并且大小和布局也未更改,界面可能会跳过这些阶段。
var padding by remember {
mutableStateOf(8.dp) }
Text(
text = "Hello",
// The `padding` state is read in the composition phase
// when the modifier is constructed.
// Changes in `padding` will invoke recomposition.
modifier = Modifier.padding(padding)
)
第 2 阶段:布局
布局阶段的主要目的是为了对视图树中的每个 LayoutNode 节点进行测量和摆放。
布局阶段包含两个步骤:测量和放置。测量步骤会运行传递给 Layout 可组合项的测量 lambda、LayoutModifier
接口的 MeasureScope.measure
方法,等等。放置步骤会运行 layout
函数的放置位置块、Modifier.offset { … }
的 lambda
块,等等。
每个步骤的状态读取都会影响布局阶段,并且可能会影响绘制阶段。当状态值发生更改时,Compose 界面会安排布局阶段。如果大小或位置发生更改,界面还会运行绘制阶段。
更确切地说,测量步骤和放置步骤分别具有单独的重启作用域,这意味着,放置步骤中的状态读取不会在此之前重新调用测量步骤。不过,这两个步骤通常是交织在一起的,因此在放置步骤中读取的状态可能会影响属于测量步骤的其他重启作用域。
var offsetX by remember {
mutableStateOf(8.dp) }
Text(
text = "Hello",
modifier = Modifier.offset {
// The `offsetX` state is read in the placement step
// of the layout phase when the offset is calculated.
// Changes in `offsetX` restart the layout.
IntOffset(offsetX.roundToPx(), 0)
}
)
布局阶段用来对视图树中每个LayoutNode节点进行宽高尺寸测量并完成位置摆放。在Compose中,父节点会向子节点传递布局约束,布局约束中包含了父节点允许子节点的 最大宽高 和 最小宽高,当父节点希望子节点测量的宽高为某个具体的值时,约束中的最大宽高和最小宽高就是相同的。子节点根据父节点传给自己的布局约束进行自我测量。
在Compose
框架中,LayoutNode不允许被多次测量,换句话说就是每个子元素只允许被测量一次。这意味着,你不能为了尝试不同的测量配置而多次测量任何子元素。在 Compose 的世界中,这个规则是强制性的,如果你不小心对某个子元素进行了多次测量,那么Compose会直接抛出异常导致应用崩溃。但在传统View
中,每个父View
可以对子View
进行一次或多次测量,因此很容易导致测量次数发生指数级爆炸。所以传统View
的性能优化点之一就是减少xml
中布局的嵌套层级,而在Compose
中组件的多级嵌套也不会导致此问题。这也是为什么Compose
比传统View
性能高的原因之一。
在 Compose 中,每个界面元素都有一个父元素,还可能有多个子元素。每个元素在其父元素中都有一个位置,指定为 (x, y)
位置;也都有一个尺寸,指定为 width
和 height
。
在界面树中每个节点的布局过程分为三个步骤:
- 测量所有子项
- 确定自己的尺寸
- 放置其子项
在测量完所有子元素的尺寸后,父元素才会对子元素进行摆放。在摆放时,系统会遍历视图树,执行所有的place
命令。
第 3 阶段:绘制
绘制代码期间的状态读取会影响绘制阶段。常见示例包括 Canvas()
、Modifier.drawBehind
和 Modifier.drawWithContent
。当状态值发生更改时,Compose
界面只会运行绘制阶段。
var color by remember {
mutableStateOf(Color.Red) }
Canvas(modifier = modifier) {
// The `color` state is read in the drawing phase
// when the canvas is rendered.
// Changes in `color` restart the drawing.
drawRect(color)
}
Jetpack Compose 中的自定义布局
1. 使用 Modifier.layout() 自定义布局
当我们需要进行自定义布局时,首选推荐的就是Modifier.layout()
修饰符,通过使用 Modifier.layout()
可以手动控制元素的测量和布局。
Modifier.layout {
measurable, constraints ->
...
}
当使用 Modifier.layout()
修饰符时,传入的回调 lambda 需要包含两个参数:measurable
、constraints
measurable
:子元素LayoutNode的测量句柄,通过调用其提供的measure()
方法完成LayoutNode的测量。constraints
: 来自父LayoutNode的布局约束,包括最大宽高值与最小宽高值。
Modifier.layout() 使用示例:自定义 paddingFromBaseline
使用 Compose 的 Text 组件时,有时希望指定 Text 顶部到文本基线的高度,让文本看的更自然一些,使用内置的 padding
修饰符是无法满足需求的,因为padding
修饰符只能指定 Text 顶部到文本顶部的高度。虽然 Compose 已经提供了 paddingFromBaseline
修饰符来解决这个问题,不妨用 layout
修饰符来自己实现一个试试。
我们创建一个 paddingBaselineToTop
的自定义修饰符,实现代码如下:
fun Modifier.paddingBaselineToTop(padding : Dp = 0.dp) = layout {
measurable, constraints ->
val placeable = measurable.measure(constraints) // 使用父节点约束进行自我测量
check(placeable[FirstBaseline] != AlignmentLine.Unspecified) // 保证组件存在内容基线
val firstBaseline = placeable[FirstBaseline] // 基线高度
val paddingTop = padding.roundToPx() - firstBaseline // [设置的基线到顶部的距离] - [基线的高度]
// 仅改变高度为:高度 + paddingTop
layout(placeable.width, placeable.height + paddingTop) {
placeable.placeRelative(0, paddingTop) // y坐标向下偏移paddingTop距离
}
}
接下来说明一下在上面代码中发生了什么:
- 正如前面布局阶段中所提到的,每个LayoutNode只允许被测量一次,这里使用了
measurable.measure(constraints)
方法来完成子元素的测量,这里将父节点的constraints
约束参数直接传入了子节点的measure
方法中进行测量,这意味着:你将父节点的布局约束限制直接提供给了当前的子元素,自身没有增加任何额外的限制。 - 子元素测量的结果被包装在一个
Placeable
实例中,稍后即可通过该Placeable
实例来获取到刚刚子元素测量结果。 - 完成测量流程后,接下来就是布局过程,这需要调用
layout(width, height)
方法对当前元素的宽度与高度进行指定,这里将高度增加了paddingTop
距离。 - 最后在
layout(){ }
的 lambda 中调用placeable.place(x, y)
或placeable.placeRelative(x, y)
(支持RTL)进行位置摆放。
另外需要说明的一点是:作用域的使用决定了您可以衡量和放置子项的时机。即只能在测量和布局传递期间(即 MeasureScope 作用域中)测量布局,并且只能在布局传递期间(即 PlacementScope 作用域中)才能放置子项(且要在已进行测量之后)。此操作在编译时强制执行,所以不用担心你的代码放错了地方,编译器会提醒你。
接下来预览一下效果:
@Preview
@Composable
fun LayoutModifierExample() {
Box(Modifier.background(Color.Green)){
// 设置和Modifier.paddingFromBaseline相同的效果
Text(text = "paddingFromBaseline", Modifier.paddingBaselineToTop(25.dp))
}
}
在熟悉了layout修饰符的使用流程之后,就可以根据业务需求自己定义更多的自定义修饰符来使用。
例如,下面是实现自定义类似系统内置的offset
修饰符的功能:
@Composable
fun LayoutModifierExample() {
Box(Modifier.background(Color.Red)) {
Text(text = "Offset", Modifier.myOffset(5.dp)) // 设置和 Modifier.offset(5.dp) 相同的效果
}
}
fun Modifier.myOffset(x : Dp = 0.dp, y : Dp = 0.dp) = layout {
measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height) {
placeable.placeRelative(x.roundToPx(), y.roundToPx())
}
}
Modifier.layout() 实例:实现聊天气泡框
下面的例子实现了一个聊天对话框中消息气泡的效果:
@Composable
fun BubbleBox(
modifier : Modifier = Modifier,
text: String = "",
fontSize : TextUnit = 18.sp,
textColor : Color = Color.Black,
arrowWidth: Dp = 16.dp,
arrowHeight: Dp = 16.dp,
arrowOffset: Dp = 8.dp,
arrowDirection: ArrowDirection = ArrowDirection.Left,
elevation: Dp = 2.dp,
backgroundColor: Color = Color(0xffE7FFDB),
padding: Dp = 8.dp
) {
Box(
modifier.drawBubble(
arrowWidth = arrowWidth,
arrowHeight = arrowHeight,
arrowOffset = arrowOffset,
arrowDirection = arrowDirection,
elevation = elevation,
color = backgroundColor
)
.padding(padding)
) {
Text(text = text, fontSize = fontSize, color = textColor)
}
}
fun Modifier.drawBubble(
arrowWidth: Dp,
arrowHeight: Dp,
arrowOffset: Dp,
arrowDirection: ArrowDirection,
elevation: Dp = 0.dp,
color: Color = Color.Unspecified
) = composed {
val arrowWidthPx: Float = arrowWidth.toPx()
val arrowHeightPx: Float = arrowHeight.toPx()
val arrowOffsetPx: Float = arrowOffset.toPx()
val shape = remember(arrowWidth, arrowHeight, arrowOffset, arrowDirection) {
createBubbleShape(arrowWidthPx, arrowHeightPx, arrowOffsetPx, arrowDirection)
}
// 阴影和形状
val shadowShapeModifier = Modifier.shadow(elevation, shape, spotColor = Color.Red, ambientColor = Color.Black)
val shapeModifier = if (elevation > 0.dp) shadowShapeModifier else Modifier.clip(shape)
Modifier.then(shapeModifier)
.background(color, shape)
.layout {
measurable, constraints ->
val isHorizontalArrow =
arrowDirection == ArrowDirection.Left || arrowDirection == ArrowDirection.Right
val isVerticalArrow =
arrowDirection == ArrowDirection.Top || arrowDirection == ArrowDirection.Bottom
// 箭头偏移量
val offsetX = if (isHorizontalArrow) arrowWidthPx.toInt() else 0
val offsetY = if (isVerticalArrow) arrowHeightPx.toInt() else 0
// 测量文本 根据箭头偏移量来设置文本的约束偏移信息
val placeable = measurable.measure(
constraints.offset(horizontal = -offsetX, vertical = -offsetY)
)
// val placeable = measurable.measure(constraints)
// 总宽度为文本宽度+箭头宽度
val width = constraints.constrainWidth(placeable.width + offsetX)
// 总高度为文本高度+箭头高度
val height = constraints.constrainHeight(placeable.height + offsetY)
val posX = when (arrowDirection) {
ArrowDirection.Left -> arrowWidthPx.toInt()
else -> 0
}
val posY = when (arrowDirection) {
ArrowDirection.Top -> arrowHeightPx.toInt()
else -> 0
}
layout(width, height) {
placeable.placeRelative(posX, posY) // 摆放文本
}
}
}
enum class ArrowDirection {
Left, Right, Top, Bottom }
fun createBubbleShape(
arrowWidth: Float,
arrowHeight: Float,
arrowOffset: Float,
arrowDirection: ArrowDirection
): GenericShape {
return GenericShape {
size: Size, layoutDirection: LayoutDirection ->
val width = size.width
val height = size.height
val rect = RoundRect(
rect = Rect(0f, 0f, width, height),
cornerRadius = CornerRadius(x = 20f, y = 20f)
)
when (arrowDirection) {
ArrowDirection.Left -> {
moveTo(arrowWidth, arrowOffset)
lineTo(0f, arrowOffset)
lineTo(arrowWidth, arrowHeight + arrowOffset)
addRoundRect(rect.copy(left = arrowWidth))
}
ArrowDirection.Right -> {
moveTo(width - arrowWidth, arrowOffset)
lineTo(width, arrowOffset)
lineTo(width - arrowWidth, arrowHeight + arrowOffset)
addRoundRect(rect.copy(right = width - arrowWidth))
}
ArrowDirection.Top -> {
moveTo(arrowOffset, arrowHeight)
lineTo(arrowOffset + arrowWidth / 2, 0f)
lineTo(arrowOffset + arrowWidth, arrowHeight)
addRoundRect(rect.copy(top = arrowHeight))
}
ArrowDirection.Bottom -> {
moveTo(arrowOffset, height - arrowHeight)
lineTo(arrowOffset + arrowWidth / 2, height)
lineTo(arrowOffset + arrowWidth, height - arrowHeight)
addRoundRect(rect.copy(bottom = height - arrowHeight))
}
}
}
}
使用:
@Composable
fun BubbleBoxExample() {
Column(
modifier = Modifier.background(Color(0xffFBE9E7)).padding(8.dp).fillMaxWidth()
) {
val message1 = "脱水!".repeat(10)
val message2 = "浸泡!".repeat(10)
BubbleBox(
text = message1,
arrowWidth = 16.dp,
arrowHeight = 16.dp,
arrowOffset = 8.dp,
arrowDirection = ArrowDirection.Left,
backgroundColor = Color(0xFFDEF3D4),
)
Spacer(Modifier.height(10.dp))
Box(modifier = Modifier.fillMaxWidth()) {
BubbleBox(
text = message2,
modifier = Modifier.align(Alignment.CenterEnd),
arrowWidth = 16.dp,
arrowHeight = 16.dp,
arrowOffset = 8.dp,
arrowDirection = ArrowDirection.Right,
backgroundColor = Color(0xFF7EBAE2),
)
}
Spacer(Modifier.height(10.dp))
BubbleBox(
text = message1,
fontSize = 16.sp,
arrowWidth = 24.dp,
arrowHeight = 16.dp,
arrowOffset = 10.dp,
arrowDirection = ArrowDirection.Top,
backgroundColor = Color.Magenta,
)
Spacer(Modifier.height(10.dp))
BubbleBox(
text = message2,
fontSize = 16.sp,
arrowWidth = 24.dp,
arrowHeight = 16.dp,
arrowOffset = 10.dp,
arrowDirection = ArrowDirection.Bottom,
backgroundColor = Color.Cyan,
)
}
}
显示效果:
2. 使用 Layout 组件自定义布局
除了Modifier.layout()
修饰符,Compose 还提供了一个叫 Layout 的 Composable 组件,可以直接在 Composable 函数中调用,方便自定义布局。
前面的 Layout Modifier 似于传统 View 系统的 View 单元定制,而对于 “ViewGroup” 场景的定制,就需要使用 Layout 组件了。
@Composable
fun CustomLayout(
modifier: Modifier = Modifier,
// custom layout attributes
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) {
measurables, constraints ->
// measure and position children given constraints logic here
}
}
可以看到,Layout 需要填写三个参数:modifier
,content
,measurePolicy
modifier
:由外部传入的修饰符,会决定该 UI 元素的 constraintscontent
:content是一个槽位,注意它的类型是一个Composable函数类型,在 content 中可以放置所有子元素measurePolicy
:测量策略,默认场景下只实现measure
方法即可,上面示例中最后传入的 lambda 就是measure
方法的实现。
Layout 组件使用示例:自定义 MyColumn 组件
下面通过 Layout 组件定制一个自己专属的 Column,下面是实现代码:
@Composable
fun MyColumn(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Layout(content = content, modifier = modifier) {
measurables, constraints ->
val placeables = measurables.map {
measurable ->
measurable.measure(constraints)
}
var yOffset = 0
layout(constraints.maxWidth, constraints.maxHeight) {
placeables.forEach {
placeable ->
placeable.placeRelative(x = 0, y = yOffset)
yOffset += placeable.height
}
}
}
}
和 Modifier.layout()
修饰符一样,我们需要对所有子组件进行一次测量。切记,每个子元素只允许被测量一次。
与Modifier.layout()
修饰符不同的是,这里 Layout 组件的 measurePolicy
提供的 measure
方法反回的 measurables
是一个 List
,而在layout
修饰符中则只是一个 measurable
,因为它将所有子元素看作了一个整体。
在上面的示例中仍然不对子元素进行额外限制,最终将测量的结果保存到一个 placeables
的 List
中。 出于简单考虑,在调用 layout(width, height)
方法时,选择将宽度与高度设置为其父元素所允许的最大高度与宽度。 在调用placeable.placeRelative(x, y)
摆放子元素的时候,由于 Column 是需要将子元素进行垂直排列的,所以仅需简单的将y坐标堆叠起来即可。
使用方式:
@Composable
fun CustomLayoutExample() {
MyColumn(Modifier.padding(10.dp)) {
Text(text = "MyColumn")
Text(text = "AAAAAAAAAAAAAA")
Text(text = "BBBBBBBBBBBBB")
Text(text = "DDDDD")
}
}
预览一下效果:
@Preview(showBackground = true)
@Composable
fun CustomLayoutExamplePreview() {
Box(Modifier.height(200.dp)) {
CustomLayoutExample()
}
}
前面提到 Layout 组件得到的 measurables
是一个 List
,如果想从这个列表中查找某个子元素进行测量,可以通过为每个元素指定一个layoutId
来查找,例如:
MyLayout(Modifier.fillMaxWidth().border(1.dp, Color.Cyan)) {
Image(
modifier = Modifier.size(150.dp).layoutId("image"), // 指定 layoutId
...
)
Text(
modifier = Modifier.border(2.dp, Color.Red).layoutId("text"), // 指定 layoutId
text = "Hello world"
)
}
@Composable
private fun MyLayout(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Layout(modifier = modifier, content = content) {
measurables, constraints ->
val imagePlaceable = measurables.firstOrNull {
it.layoutId == "image" }?.measure( // 根据 layoutId 查找
constraints.copy(minWidth = 0, minHeight = 0)
)
val textPlaceable = measurables.firstOrNull {
it.layoutId == "text" }?.measure( // 根据 layoutId 查找
constraints.copy(
minWidth = imagePlaceable?.width ?: constraints.minWidth,
maxWidth = imagePlaceable?.width ?: constraints.maxWidth
)
)
val width = imagePlaceable?.width ?: constraints.minWidth
val imagePlaceableHeight = imagePlaceable?.height ?: 0
val height = imagePlaceableHeight + (textPlaceable?.height ?: 0)
layout(width, height) {
imagePlaceable?.placeRelative(0, 0)
textPlaceable?.placeRelative(0, imagePlaceableHeight)
}
}
}
Layout 组件实例:实现 StaggeredGrid 瀑布流布局
再来看一个例子,下面代码使用 Layout 组件实现了一个 StaggeredGrid 瀑布流布局,它根据上一行组件的最大高度来对齐下一行的组件,并且自动换行显示:
@Composable
fun StaggeredGrid(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Layout(content = content, modifier = modifier) {
measurables, constraints ->
val constraintMaxWidth = constraints.maxWidth
var maxRowWidth = 0
var currentWidthOfRow = 0
var totalHeightOfRows = 0
val placeableMap = linkedMapOf<Int, Point>()
val rowHeights = mutableListOf<Int>()
var maxPlaceableHeight = 0
var lastRowHeight = 0
val placeables = measurables.mapIndexed {
index, measurable ->
// 测量每个Child
val placeable = measurable.measure(
// 不限制每个Child的宽度,让child决定自己有多宽
constraints.copy(minWidth = 0, maxWidth = Constraints.Infinity)
)
// 测量完成后,每个Child的宽高
val placeableWidth = placeable.width
val placeableHeight = placeable.height
// 如果【当前行的宽度总和】+【当前placeable的宽度】<= 父布局的最大宽度
// 那么说明当前placeable应该归属到当前行中,作为同一行展示
val isSameRow = (currentWidthOfRow + placeableWidth <= constraintMaxWidth)
val xPos = if (isSameRow) currentWidthOfRow else 0
val yPos: Int
if (isSameRow) {
// 更新当前行的宽度总和:将当前placeable的宽度累加到当前行的宽度总和中
currentWidthOfRow += placeableWidth
// 记录当前行中的最大高度
maxPlaceableHeight = maxPlaceableHeight.coerceAtLeast(placeableHeight)
// 记录最长的那一行的最大宽度
maxRowWidth = maxRowWidth.coerceAtLeast(currentWidthOfRow)
lastRowHeight = maxPlaceableHeight // 最后一行的最大高度
yPos = totalHeightOfRows // 当前行的y坐标是到上一行为止的所有行的最大高度之和
} else {
// 当前placeable不在同一行,另起一行的逻辑
currentWidthOfRow = placeableWidth
totalHeightOfRows += maxPlaceableHeight // 每次换行时累加总高度,把上一行的最大高度加进去
yPos = totalHeightOfRows // 当前行的y坐标是到上一行为止的所有行的最大高度之和
rowHeights.add(maxPlaceableHeight) // 收集每一行的最大高度
maxPlaceableHeight = placeableHeight // 新的一行中开始比较的最大高度的初始值
lastRowHeight = maxPlaceableHeight // 最后一行的最大高度
}
placeableMap[index] = Point(xPos, yPos)
placeable
}
// 计算总高度
val totalHeight = (rowHeights.sumOf {
it } + lastRowHeight) // 换到下一行时才会收集上一行的,因此最后缺少一行
val finalHeight = constraints.constrainHeight(totalHeight) // 高度限制在父约束[minHeight, maxHeight]之间
maxRowWidth = constraints.constrainWidth(maxRowWidth) // 宽度限制在父约束[minWidth, maxWidth]之间
// 设置布局的大小尽可能大
layout(maxRowWidth, finalHeight) {
placeables.forEachIndexed {
index, placeable ->
val point = placeableMap[index]
point?.let {
placeable.placeRelative(x = it.x, y = it.y) }
}
}
}
}
测试代码如下:
@Composable
fun StaggeredGridExample(tips: List<String>) {
val screenWidth = Resources.getSystem().displayMetrics.widthPixels.toFloat()
var width by remember {
mutableStateOf(screenWidth*0.8f) }
Column(Modifier.padding(5.dp).fillMaxSize().verticalScroll(rememberScrollState())) {
Slider(value = width, onValueChange = {
width = it}, valueRange = 0f..screenWidth)
Text("StaggeredGrid width: ${
width.toDp()}")
Spacer(modifier = Modifier.height(10.dp))
StaggeredGrid(
Modifier
//.fillMaxWidth()
.width(width.toDp())
.border(3.dp, Color.Magenta)
) {
tips.shuffled().forEachIndexed {
index, tip ->
Box(
Modifier
.clip(RoundedCornerShape(50))
.background(colors[index % colors.size])
.widthIn(min = 50.dp, max = 200.dp),
contentAlignment = Alignment.Center
) {
Text(
text = tip,
color = Color.White,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 5.dp)
)
}
}
}
}
}
@Composable
fun StaggeredGridExampleTest() {
val tips = listOf("Compose", "Android", "扎心了,老铁", "干饭了", "内卷",
"StaggeredGrid", "锦鲤", "整个人都不好了", "卧槽", "无图无真相", "点赞收藏",
"在线等, 挺急的", "打工人", "是个狠人", "佛系交友", "火鸡科学家", "你是要把我笑死,然后继承我的花呗吗",
"补刀", "求生欲", "辣眼睛", "天生我材必有用,千金散尽还复来", "不忘初心", "割韭菜", "惊不惊喜,意不意外", "社交牛逼症", "安排", "YYDS",
"奥利给", "内容引起极度舒适", "OMG", "笑容渐渐凝固", "XSWL", "莫愁前路无知己,天下谁人不识君", "疑车无据", "快放我下车",
"听君一些话,如听一席话", "盘他", "躺平", "阳康了吗", "做核酸", "绿码", "爷青回", "元气满满", "黄沙百战穿金甲")
StaggeredGridExample(tips)
}
为了查看自定义的StaggeredGrid
在不同宽度下的表现,所以这里使用了Slider
动态控制StaggeredGrid
的宽度,实际中你可以直接对StaggeredGrid
应用fillMaxWidth()
或固定宽度值。
运行效果如下:
下面是本示例代码实现的核心要点:
- 测量每个Child时,不对其宽度进行约束限制,每个Child可以自由决定自己显示多大。
- 如何实现自动换行:逐步累加每一个Child的宽度,当宽度超过父约束的最大宽度值时,就应该另起一行放置该Child,否则该Child就放在同一行。
- 每个Child的x轴坐标计算:如果是同一行的Child,x坐标就是不断累加的当前行的总宽度,如果是另起一行的Child,则x坐标就是0。
- 每个Child的y轴坐标计算:每次换到下一行时,累加上一行中的最大高度值。如果Child没有换行,则y坐标就是这个累加的值。
- StaggeredGrid的总宽度计算:所有行中的宽度总和的最大值,即最长的那一行。注意需要使用父约束对象对总宽度进行约束,防止溢出。
- StaggeredGrid的总高度计算:在每次换行时,收集上一行中的最大高度值到一个List集合中,最后累加起来,但是由于是换到下一行时才会收集上一行的,因此最后会缺少一行的最大高度,因此记得要把最后一行的高度算进去。同样注意需要使用父约束对象对总高度进行约束,防止溢出。
3. Layout / Modifier.layout 小结
最后,针对 Modifier.layout() 修饰符和 Layout 组件的使用进行一个简要总结(因为二者在使用上是类似的):
总的来说,就是在 Modifier.layout() {}
和 Layout() {}
的 lambda 中执行下面三个小步骤:
- ① 使用父约束对子组件进行测量,返回
placeable
句柄。(可选修改或定制约束信息) - ② 计算当前组件自身的总宽度和总高度信息(根据 ① 中返回的测量结果计算)。
- ③ 在
layout(width, height)
方法中传入 ② 中计算得到的总宽高,并在layout(width, height) {}
的 lambda 中对子组件进行摆放(调用placeable.placeRelative(x, y)
方法)。
并且我再次补充说明一下 Modifier.layout() 修饰符和 Layout 组件之间的使用区别:前者主要用于针对单个组件的定制,因为这时可能我们想要的不是在一个容器内包装很多个复杂的子组件,而是针对某个组件单元应用一些自定义的效果;而后者悄悄相反,这时说明我们的自定义组件使用到多个子组件,并需要处理它们之间的关系。
Jetpack Compose 中的 Constraints 约束详解
基本原则
在 Jetpack Compose 中自定义布局时,需要永远记住的一条黄金法则就是:父组件向子组件传递约束条件,子组件根据父组件传递的约束条件来决定如何测量子元素。
约束条件限制了元素的 width
和 height
的最小值和最大值。如果某个元素拥有子元素,它可能会测量每个子元素,以帮助确定其尺寸。
约束条件会在整个视图树中从上往下进行传递,而测量结果的尺寸大小(表现为 placeable
对象)则会沿着视图树从下向上反馈:
只有在所有子元素测量完确定了尺寸大小之后,父组件才知道该如何摆放它们(执行place操作)。
约束条件由 Constraints 对象表示,它包含了如下四个值:
实例代码分析
下面通过以下示例代码来理解一下约束条件的传递对结果的影响:
@Composable
private fun CustomColumnWithDefaultConstraints(
modifier: Modifier,
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) {
measurables: List<Measurable>, constraints: Constraints ->
createCustomColumnLayout(measurables, constraints, constraints)
}
}
private fun MeasureScope.createCustomColumnLayout(
measurables: List<Measurable>,
constraints: Constraints,
updatedConstraints: Constraints
): MeasureResult {
// 使用 updatedConstraints 约束测量每个 child
val placeables = measurables.map {
measurable ->
measurable.measure(updatedConstraints)
}
val totalHeight = placeables.sumOf {
it.height }
// 这里可选的操作一般有:
// 1) 选择所有子元素中的一个最大的宽(或高)来作为自身的宽(或高)
// val contentWidth = placeables.maxOf { it.width }
// 2) 累加所有子元素的宽(或高)作为自身的宽(或高)
// val contentWidth = placeables.sumOf { it.width }
// 3) 直接使用传入的父组件的约束条件中的宽(或高)作为自身的宽(或高)
val contentWidth = constraints.maxWidth
// 4) 修改传入的父组件的约束条件中的某个值,使用更新后的约束信息
// val contentWidth = updatedConstraints.maxWidth
// 设置布局的大小尽可能的大
return layout(contentWidth, totalHeight) {
// 在当前组件中摆放每个child
var y = 0
placeables.forEach {
placeable ->
placeable.placeRelative(x = 0, y = y)
y += placeable.height
}
}
}
使用方式,例如:
CustomColumnWithDefaultConstraints(
modifier = Modifier.fillMaxWidth().border(2.dp, Green400)
) {
Content() }
@Composable
private fun Content() {
Text(
"First Text",
modifier = Modifier
.background(Pink400).padding(8.dp),
color = Color.White
)
Text(
"Second Text",
modifier = Modifier
.background(Blue400).padding(8.dp),
color = Color.White
)
}
其中为父组件添加了一个绿色的边框,为两个子组件添加了不同的背景色,以便区分。
下面通过尝试在 CustomColumnWithDefaultConstraints
组件的 Modifier
上应用不同的尺寸约束条件,观察并分析结果:
下面修改一下代码,自定义Layout组件测量子元素使用的约束条件不再直接使用父组件传入的约束条件,而是进行一些修改,比如将最大最小宽度值全部强制设为使用父约束的最大宽度值:
@Composable
private fun CustomColumnWithCustomConstraints(
modifier: Modifier,
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) {
measurables: List<Measurable>, constraints: Constraints ->
val updatedConstraints = constraints.copy( // 拷贝一份父约束进行修改
minWidth = constraints.maxWidth,
maxWidth = constraints.maxWidth
)
createCustomColumnLayout(measurables, constraints, updatedConstraints)
}
}
其中 createCustomColumnLayout
方法和前面的一样。
调用方式还是和前面类似:
CustomColumnWithCustomConstraints(
modifier = Modifier
.wrapContentSize()
.border(2.dp, Green400)
) {
Content() }
同样通过尝试在 CustomColumnWithDefaultConstraints
组件的 Modifier
上应用不同的尺寸约束条件,观察并分析结果:
跟前面一样进行其他几种情况的应用后发现,结果都类似,所以这里只列出这一种情况的分析结果。因为子组件的约束信息都使用了固定宽度值来测量,测量结果的宽度总是保持和父组件的maxWidth
一致。可以通过下图进行对比:
下面继续修改代码,将自定义Layout组件测量子元素使用的约束条件改为完全自定义的约束条件,例如改成一个拥有指定宽度值的约束对象:
@Composable
private fun CustomColumnWithCustomConstraints2(
modifier: Modifier,
content: @Composable () -> Unit
) {
val widthInPx = with