前言
在之前的文章中,我们通过传统View实现过环形菜单
Compose为什么能快速开发UI,除了Kotlin语法糖等加持之外,其Modifier功能也非常强大,但是在开发的过程中,也会遇到让人比较难以理解的行为,比如其Modifier.layout有一定的局限性,无法获取到所有Child Node的相关信息。
本篇,我们这里会实现一种环形菜单,也会分析一下MeasurePolicy相关的用法和设计思想,也会简单介绍下官方的一些方法。
关于布局方式
相较于传统的View布局,Compose UI的布局和测量是一起的,传统的View是measure和layout存在一定的隔离,即所有的view都测量完成,才会进行真正的layout。但有时,需要进行强行关联,比如在实现Flow布局时,传统的ViewGroup需要做一些缓存信息来服务layout。而compose UI是边测量边布局,使得measure和layout隔离程度减少,显然应该有一定的其他方面的想法,具体是什么呢,继续往下看。
那么,如果想要在Compose实现布局怎么实现呢?
其实,Compose 官方给出了很多实现方式
扩展Modifier属性
这种方式,通过扩展Modifer属性实现布局,但是仅仅对Compose自身有用,对child Node无效。
java
fun Modifier.firstBaselineToTop(
firstBaselineToTop: Dp
) = layout { measurable, constraints ->
// Measure the composable
val placeable = measurable.measure(constraints)
// Check the composable has a first baseline
check(placeable[FirstBaseline] != AlignmentLine.Unspecified)
val firstBaseline = placeable[FirstBaseline]
// Height of the composable with padding - first baseline
val placeableY = firstBaselineToTop.roundToPx() - firstBaseline
val height = placeable.height + placeableY
layout(placeable.width, height) {
// Where the composable gets placed
placeable.placeRelative(0, placeableY)
}
}
使用方式如下,下面是直接影响Text的布局
java
@Preview
@Composable
fun TextWithPaddingToBaselinePreview() {
MyApplicationTheme {
Text("Hi there!", Modifier.firstBaselineToTop(32.dp))
}
}
Layout扩展
下面是官方网站的一套代码,我们可以进行参考,这种方式可以约束到child Node,实际上本篇内容也可以使用这种方式实现,但是我们的主题是MeasurePolicy,因此就没用这种方式
java
@Composable
fun MyBasicColumn(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
// Don't constrain child views further, measure them with given constraints
// List of measured children
val placeables = measurables.map { measurable ->
// Measure each children
measurable.measure(constraints)
}
// Set the size of the layout as big as it can
layout(constraints.maxWidth, constraints.maxHeight) {
// Track the y co-ord we have placed children up to
var yPosition = 0
// Place children in the parent layout
placeables.forEach { placeable ->
// Position item on the screen
placeable.placeRelative(x = 0, y = yPosition)
// Record the y co-ord placed up to
yPosition += placeable.height
}
}
}
}
MeasurePolicy
MeasurePolicy 字面意思是测量策略,在使用Compose时会作为参数传入Layout,但是如果将其理解为测量显然是不正确的,因为MeasurePolicy 不仅仅可以测量,还能实现布局,该方法名称还是有一定的误导性质的。
java
@UiComposable
@Composable
inline fun Layout(
content: @Composable @UiComposable () -> Unit,
modifier: Modifier = Modifier,
measurePolicy: MeasurePolicy
) {
val compositeKeyHash = currentCompositeKeyHash
val localMap = currentComposer.currentCompositionLocalMap
ReusableComposeNode<ComposeUiNode, Applier<Any>>(
factory = ComposeUiNode.Constructor,
update = {
set(measurePolicy, SetMeasurePolicy)
set(localMap, SetResolvedCompositionLocals)
@OptIn(ExperimentalComposeUiApi::class)
set(compositeKeyHash, SetCompositeKeyHash)
},
skippableUpdate = materializerOf(modifier),
content = content
)
}
我们来看看MeasurePolicy在Box组件中的用法,下面代码中我添加了一些注释,方便理解
java
internal fun boxMeasurePolicy(alignment: Alignment, propagateMinConstraints: Boolean) =
MeasurePolicy { measurables, constraints ->
if (measurables.isEmpty()) {
return@MeasurePolicy layout(
constraints.minWidth,
constraints.minHeight
) {}
//如果没有childNode,直接返回
}
val contentConstraints = if (propagateMinConstraints) {
constraints
} else {
constraints.copy(minWidth = 0, minHeight = 0)
}
if (measurables.size == 1) {
//如果child node数量为1,走这部分逻辑,显然Box支持放多个Compose 组件
val measurable = measurables[0]
val boxWidth: Int
val boxHeight: Int
val placeable: Placeable
if (!measurable.matchesParentSize) {
placeable = measurable.measure(contentConstraints)
boxWidth = max(constraints.minWidth, placeable.width)
boxHeight = max(constraints.minHeight, placeable.height)
} else {
boxWidth = constraints.minWidth
boxHeight = constraints.minHeight
placeable = measurable.measure(
Constraints.fixed(constraints.minWidth, constraints.minHeight)
)
}
return@MeasurePolicy layout(boxWidth, boxHeight) {
placeInBox(placeable, measurable, layoutDirection, boxWidth, boxHeight, alignment)
}
}
val placeables = arrayOfNulls<Placeable>(measurables.size)
// First measure non match parent size children to get the size of the Box.
var hasMatchParentSizeChildren = false
var boxWidth = constraints.minWidth
var boxHeight = constraints.minHeight
measurables.fastForEachIndexed { index, measurable ->
if (!measurable.matchesParentSize) {
val placeable = measurable.measure(contentConstraints)
placeables[index] = placeable
boxWidth = max(boxWidth, placeable.width)
boxHeight = max(boxHeight, placeable.height)
} else {
hasMatchParentSizeChildren = true
}
}
//获取到match parent的child node信息,重新测量
// Now measure match parent size children, if any.
if (hasMatchParentSizeChildren) {
// The infinity check is needed for default intrinsic measurements.
val matchParentSizeConstraints = Constraints(
minWidth = if (boxWidth != Constraints.Infinity) boxWidth else 0,
minHeight = if (boxHeight != Constraints.Infinity) boxHeight else 0,
maxWidth = boxWidth,
maxHeight = boxHeight
)
measurables.fastForEachIndexed { index, measurable ->
if (measurable.matchesParentSize) {
placeables[index] = measurable.measure(matchParentSizeConstraints)
}
}
}
// Specify the size of the Box and position its children.
// 布局child Node
layout(boxWidth, boxHeight) {
placeables.forEachIndexed { index, placeable ->
placeable as Placeable
val measurable = measurables[index]
placeInBox(placeable, measurable, layoutDirection, boxWidth, boxHeight, alignment)
}
}
}
代码实现很复杂,但是为什么Compose UI种都往往会使用MeasurePolicy呢,主要原因是通过减少对Compose 组件的修改,实现更多的UI表现。这点理念其实很像recyclerView的LayoutManager。
当然,这个设计思想其实都是为了减少测量
下面是《Jetpack Compose 博物馆》的总结
composable 被调用时会将自身包含的UI元素添加到UI树中并在屏幕上被渲染出来。每个 UI 元素都有一个父元素,可能会包含零至多个子元素。每个元素都有一个相对其父元素的内部位置和尺寸。
每个元素都会被要求根据父元素的约束来进行自我测量(类似传统 View 中的 MeasureSpec ),约束中包含了父元素允许子元素的最大宽度与高度和最小宽度与高度,当父元素想要强制子元素宽高为固定值时,其对应的最大值与最小值就是相同的。
对于一些包含多个子元素的UI元素,需要测量每一个子元素从而确定当前UI元素自身的大小。并且在每个子元素自我测量后,当前UI元素可以根据其所需要的宽度与高度进行在自己内部进行放置
结合代码,我们从其中就能看出,MeasurePolicy是一个重要的环节
java
val measurePolicy = rememberBoxMeasurePolicy(contentAlignment, propagateMinConstraints)
好了,以上是对Compose UI的一些理解,下面我们进入本篇的主题环节。
实现环形菜单
如何实现环形菜单呢?
本篇是使用MeasurePolicy去实现,但是这种往往需要我们自定义一个Compose组件,在Compose UI中,组件无法被继承,显然我们需要参考一些其他实现,这里我们选择使用Box的实现,将其代码复制为CircleBox类Compose组件。
我们这里将其核心的逻辑改在一下
java
internal fun boxMeasurePolicy(alignment: Alignment, propagateMinConstraints: Boolean) =
MeasurePolicy { measurables, constraints ->
if (measurables.isEmpty()) {
return@MeasurePolicy layout(
constraints.minWidth,
constraints.minHeight
) {}
}
val contentConstraints = if (propagateMinConstraints) {
constraints
} else {
constraints.copy(minWidth = 0, minHeight = 0)
}
if (measurables.size == 1) {
val measurable = measurables[0]
val boxWidth: Int
val boxHeight: Int
val placeable: Placeable
if (!measurable.matchesParentSize) {
placeable = measurable.measure(contentConstraints)
boxWidth = max(constraints.minWidth, placeable.width)
boxHeight = max(constraints.minHeight, placeable.height)
} else {
boxWidth = constraints.minWidth
boxHeight = constraints.minHeight
placeable = measurable.measure(
Constraints.fixed(constraints.minWidth, constraints.minHeight)
)
}
return@MeasurePolicy layout(boxWidth, boxHeight) {
placeInBox(placeable, measurable, layoutDirection, boxWidth, boxHeight, alignment)
}
}
val placeables = arrayOfNulls<Placeable>(measurables.size)
// First measure non match parent size children to get the size of the Box.
var boxWidth = constraints.minWidth
var boxHeight = constraints.minHeight
measurables.forEachIndexed { index, measurable ->
if (!measurable.matchesParentSize) {
val placeable = measurable.measure(contentConstraints)
placeables[index] = placeable
boxWidth = max(boxWidth, placeable.width)
boxHeight = max(boxHeight, placeable.height)
}
}
// 360 度圆周效果
val radian = Math.toRadians((360 / placeables.size).toDouble());
val radius = min(constraints.minWidth, constraints.minHeight) / 2;
// Specify the size of the Box and position its children.
layout(boxWidth, boxHeight) {
placeables.forEachIndexed { index, placeable ->
placeable as Placeable
val innerRadius = radius - max(placeable.height,placeable.width);
//x 轴方向
val x = cos(radian * index) * innerRadius + boxWidth / 2F - placeable.width / 2F;
// y 轴方向
val y = sin(radian * index) * innerRadius + boxHeight / 2F - placeable.height / 2F;
placeable.place(IntOffset(x.toInt(), y.toInt())) //布置item
}
}
}
通过以上代码就实现了环形布局
当然,使用起来也很简单,我们只需要将菜单Item加入到CircleBox中即可
java
class CircleMenuActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val menuItems = arrayOf("A", "B", "C", "D", "E", "F","G")
setContent {
ComposeTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
CircleBox(modifier = Modifier.fillMaxSize()) {
menuItems.forEach {
val color = Color.hsl((360 * Math.random()).toFloat(), 0.5F, 0.5F)
MenuBox(it, color);
}
}
}
}
}
}
}
@Composable
fun MenuBox(menu: String, color: Color) {
Box(
modifier = Modifier
.width(50.dp)
.height(50.dp)
.drawBehind {
drawCircle(color)
},
contentAlignment = Alignment.Center
) {
Text(text = menu);
}
}
总结
以上就是本篇的核心内容,在这篇文章中我们可以了解到MeasurePolicy的用法和设计思想。目前而言,Compose UI有很多超前的设计。有很多大家喜欢的轮子官方都给造好了,所以我们可以放更多精力在状态控制和ViewModel上,提升开发效率。
说到提升开发效率,google的程序员理论上和大家一样,都是面向老板编程,因此,尽早入局Compose UI或者Flutter显然是必要的。
遗留问题
本篇我们实现了环形菜单,但是相比java版本的要简单一些,主要是没有添加事件处理,这个其实不难,如有需要私信即可。
本篇源码
本篇我们无法继承Box,而是复制了Box,对其进行了改写,主要代码如下
java
@Composable
inline fun CircleBox(
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
propagateMinConstraints: Boolean = false,
content: @Composable CircleBoxScope.() -> Unit
) {
val measurePolicy = rememberBoxMeasurePolicy(contentAlignment, propagateMinConstraints)
Layout(
content = { CircleBoxScopeInstance.content() },
measurePolicy = measurePolicy,
modifier = modifier
)
}
@PublishedApi
@Composable
internal fun rememberBoxMeasurePolicy(
alignment: Alignment,
propagateMinConstraints: Boolean
) = if (alignment == Alignment.TopStart && !propagateMinConstraints) {
DefaultBoxMeasurePolicy
} else {
remember(alignment, propagateMinConstraints) {
boxMeasurePolicy(alignment, propagateMinConstraints)
}
}
internal val DefaultBoxMeasurePolicy: MeasurePolicy = boxMeasurePolicy(Alignment.TopStart, false)
internal fun boxMeasurePolicy(alignment: Alignment, propagateMinConstraints: Boolean) =
MeasurePolicy { measurables, constraints ->
if (measurables.isEmpty()) {
return@MeasurePolicy layout(
constraints.minWidth,
constraints.minHeight
) {}
}
val contentConstraints = if (propagateMinConstraints) {
constraints
} else {
constraints.copy(minWidth = 0, minHeight = 0)
}
if (measurables.size == 1) {
val measurable = measurables[0]
val boxWidth: Int
val boxHeight: Int
val placeable: Placeable
if (!measurable.matchesParentSize) {
placeable = measurable.measure(contentConstraints)
boxWidth = max(constraints.minWidth, placeable.width)
boxHeight = max(constraints.minHeight, placeable.height)
} else {
boxWidth = constraints.minWidth
boxHeight = constraints.minHeight
placeable = measurable.measure(
Constraints.fixed(constraints.minWidth, constraints.minHeight)
)
}
return@MeasurePolicy layout(boxWidth, boxHeight) {
placeInBox(placeable, measurable, layoutDirection, boxWidth, boxHeight, alignment)
}
}
val placeables = arrayOfNulls<Placeable>(measurables.size)
// First measure non match parent size children to get the size of the Box.
var boxWidth = constraints.minWidth
var boxHeight = constraints.minHeight
measurables.forEachIndexed { index, measurable ->
if (!measurable.matchesParentSize) {
val placeable = measurable.measure(contentConstraints)
placeables[index] = placeable
boxWidth = max(boxWidth, placeable.width)
boxHeight = max(boxHeight, placeable.height)
}
}
val radian = Math.toRadians((360 / placeables.size).toDouble());
val radius = min(constraints.minWidth, constraints.minHeight) / 2;
// Specify the size of the Box and position its children.
layout(boxWidth, boxHeight) {
placeables.forEachIndexed { index, placeable ->
placeable as Placeable
val innerRadius = radius - max(placeable.height,placeable.width);
val x = cos(radian * index) * innerRadius + boxWidth / 2F - placeable.width / 2F;
val y = sin(radian * index) * innerRadius + boxHeight / 2F - placeable.height / 2F;
placeable.place(IntOffset(x.toInt(), y.toInt()))
}
}
}
@Composable
fun CircleBox(modifier: Modifier) {
Layout({}, measurePolicy = EmptyBoxMeasurePolicy, modifier = modifier)
}
internal val EmptyBoxMeasurePolicy = MeasurePolicy { _, constraints ->
layout(constraints.minWidth, constraints.minHeight) {}
}
@LayoutScopeMarker
@Immutable
interface CircleBoxScope {
@Stable
fun Modifier.align(alignment: Alignment): Modifier
@Stable
fun Modifier.matchParentSize(): Modifier
}
internal object CircleBoxScopeInstance : CircleBoxScope {
@Stable
override fun Modifier.align(alignment: Alignment) = this.then(
CircleBoxChildDataElement(
alignment = alignment,
matchParentSize = false,
inspectorInfo = debugInspectorInfo {
name = "align"
value = alignment
}
))
@Stable
override fun Modifier.matchParentSize() = this.then(
CircleBoxChildDataElement(
alignment = Alignment.Center,
matchParentSize = true,
inspectorInfo = debugInspectorInfo {
name = "matchParentSize"
}
))
}
private val Measurable.boxChildDataNode: CircleBoxChildDataNode? get() = parentData as? CircleBoxChildDataNode
private val Measurable.matchesParentSize: Boolean get() = boxChildDataNode?.matchParentSize ?: false
private class CircleBoxChildDataElement(
val alignment: Alignment,
val matchParentSize: Boolean,
val inspectorInfo: InspectorInfo.() -> Unit
) : ModifierNodeElement<CircleBoxChildDataNode>() {
override fun create(): CircleBoxChildDataNode {
return CircleBoxChildDataNode(alignment, matchParentSize)
}
override fun update(node: CircleBoxChildDataNode) {
node.alignment = alignment
node.matchParentSize = matchParentSize
}
override fun InspectorInfo.inspectableProperties() {
inspectorInfo()
}
override fun hashCode(): Int {
var result = alignment.hashCode()
result = 31 * result + matchParentSize.hashCode()
return result
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
val otherModifier = other as? CircleBoxChildDataElement ?: return false
return alignment == otherModifier.alignment &&
matchParentSize == otherModifier.matchParentSize
}
}
private fun Placeable.PlacementScope.placeInBox(
placeable: Placeable,
measurable: Measurable,
layoutDirection: LayoutDirection,
boxWidth: Int,
boxHeight: Int,
alignment: Alignment
) {
val childAlignment = measurable.boxChildDataNode?.alignment ?: alignment
val position = childAlignment.align(
IntSize(placeable.width, placeable.height),
IntSize(boxWidth, boxHeight),
layoutDirection
)
placeable.place(position)
}
private class CircleBoxChildDataNode(
var alignment: Alignment,
var matchParentSize: Boolean,
) : ParentDataModifierNode, Modifier.Node() {
override fun Density.modifyParentData(parentData: Any?) = this@CircleBoxChildDataNode
}