前言

在之前的文章中,咱们经过传统View完结过环形菜单

Compose为什么能快速开发UI,除了Kotlin语法糖等加持之外,其Modifier功能也十分强大,可是在开发的过程中,也会遇到让人比较难以了解的行为,比如其Modifier.layout有必定的局限性,无法获取到一切Child Node的相关信息。

本篇,咱们这里会完结一种环形菜单,也会分析一下MeasurePolicy相关的用法和设计思维,也会简略介绍下官方的一些方法。

Android Compose运用MeasurePolicy完结环形菜单

关于布局方法

相较于传统的View布局,Compose UI的布局和丈量是一起的,传统的View是measure和layout存在必定的阻隔,即一切的view都丈量完结,才会进行真正的layout。但有时,需求进行强行相关,比如在完结Flow布局时,传统的ViewGroup需求做一些缓存信息来服务layout。而compose UI是边丈量边布局,使得measure和layout阻隔程度削减,明显应该有必定的其他方面的想法,具体是什么呢,持续往下看。

那么,假如想要在Compose完结布局怎样完结呢?

其实,Compose 官方给出了很多完结方法

扩展Modifier特点

这种方法,经过扩展Modifer特点完结布局,可是仅仅对Compose本身有用,对child Node无效。

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的布局

@Preview
@Composable
fun TextWithPaddingToBaselinePreview() {
  MyApplicationTheme {
    Text("Hi there!", Modifier.firstBaselineToTop(32.dp))
  }
}

Layout扩展

下面是官方网站的一套代码,咱们能够进行参考,这种方法能够束缚到child Node,实际上本篇内容也能够运用这种方法完结,可是咱们的主题是MeasurePolicy,因而就没用这种方法

@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 不仅仅能够丈量,还能完结布局,该方法名称仍是有必定的误导性质的。

@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组件中的用法,下面代码中我增加了一些注释,方便了解

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是一个重要的环节

val measurePolicy = rememberBoxMeasurePolicy(contentAlignment, propagateMinConstraints)

好了,以上是对Compose UI的一些了解,下面咱们进入本篇的主题环节。

完结环形菜单

如何完结环形菜单呢?

本篇是运用MeasurePolicy去完结,可是这种往往需求咱们自定义一个Compose组件,在Compose UI中,组件无法被承继,明显咱们需求参考一些其他完结,这里咱们选择运用Box的完结,将其代码复制为CircleBox类Compose组件。

咱们这里将其核心的逻辑改在一下

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中即可

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,对其进行了改写,首要代码如下

@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
}