前言
本篇效果如下
其实,在之前的《Android 自定义转盘菜单》中,我们通过传统View实现过环形旋转菜单,并且是纯绘制实现。
本篇的重点同样包括事件处理和菜单旋转,我们重点放在布局上,通过布局方式实现订单的动态配置。因此,本篇的一些元素就不走纯绘制逻辑了,本篇通过旋转Compose Node布局位置实现旋转。
简介
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属性,其次我们还可以自定义Layout,当然前者的缺陷是局限于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。
在上面的代码中,我们可以看到meaureable#measure,其主要负责测量,其参数为constraints,仅仅提供最大和最小空间,这比Android的要简单一些。
另外,其实说到测量,我们要提一下Text的测量是非常复杂的,当然,简单的测量可以通过下面的方式
textMeasurer.measure(
text = "Hello, World",
style = TextStyle(
color = Color.Red,
fontSize = 16.sp,
fontFamily = FontFamily.Cursive
),
constraints = Constraints(
minWidth = 400,
maxWidth = 400,
minHeight = 200,
maxHeight = 400
)
)
复杂Spannable文本测量就需要androidx.compose.ui.text.MultiParagraph 来测量,具体可以参考下面类中的用法
androidx.compose.foundation.text.TextDelegate
为什么要说下文本测量呢,实际上文本测量比其他的测量要稍微复杂一些。
当然,这个设计思想其实都是为了减少测量和重组。
下面是《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组件。
我们利用圆周和三角函数的关系,首先要布局菜单项,等布局实现后,同时,利用R^2 = x^2 + y^2公式就能计算出小圆和中心的距离。
我们这里将其核心的逻辑修改一下
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,y 坐标计算
//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);
}
}
效果预览
这里我们首先接入CircleBox组件,很简单的逻辑,这里就不详细说了。
基本步骤
- 定义菜单项
- 测量
- 布局
val menuItems = mapOf<String,Color>(
"A" to Color.hsl((360 * Math.random()).toFloat(), 0.5F, 0.5F),
"B" to Color.hsl((360 * Math.random()).toFloat(), 0.5F, 0.5F),
"C" to Color.hsl((360 * Math.random()).toFloat(), 0.5F, 0.5F),
"D" to Color.hsl((360 * Math.random()).toFloat(), 0.5F, 0.5F),
"E" to Color.hsl((360 * Math.random()).toFloat(), 0.5F, 0.5F),
"F" to Color.hsl((360 * Math.random()).toFloat(), 0.5F, 0.5F),
"G" to Color.hsl((360 * Math.random()).toFloat(), 0.5F, 0.5F)
)
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 {
MenuBox(it.key, it.value);
}
}
}
}
}
完成上面组合,最终效果如下
这一步我们完成了基础布局,从这里我们也可以看到,Compose的布局和传统布局一样,其坐标体系是一样的,因此A菜单会被分配到0度的位置,下面我们来实现旋转。
事件&旋转
本篇我们实现了环形菜单,但是我们没有实现菜单的旋转功能,主要是旋转部分依赖事件,需要将事件处理引入的结果传递给MeasurePolicy,这部分理论上也不难,大家也可以自己实现哦。
当然,如果你实在不想自己实现,那么本篇后面的源码也会有这部分效果的
@Composable
inline fun CircleBox(
modifier: Modifier = Modifier,
propagateMinConstraints: Boolean = false,
content: @Composable CircleBoxScope.() -> Unit
) {
var rotateDegree by remember {
mutableFloatStateOf(0F)
}
val measurePolicy = rememberBoxMeasurePolicy(Alignment.Center, propagateMinConstraints,rotateDegree)
Layout(
content = { CircleBoxScopeInstance.content() },
measurePolicy = measurePolicy,
modifier = modifier then Modifier.pointerInput("CircleBoxInputEvent"){
var startDegree = 0F
detectDragGestures { change, dragAmount ->
val dr = atan2(change.position.y.toDouble() - size.height/2f, change.position.x.toDouble() - size.width/2f);
//atan2可以计算出带象限的角度
var toFloat = (dr - startDegree).toFloat()
if(toFloat == Float.POSITIVE_INFINITY || toFloat == Float.NEGATIVE_INFINITY){
toFloat = 0F
}
rotateDegree += toFloat //更新角度
startDegree = dr.toFloat();
}
}
)
}
最终效果如下
本篇源码
本篇我们无法继承Box,而是复制了Box,将其定义为CircleBox,对其进行了改写,主要代码如下
@Composable
inline fun CircleBox(
modifier: Modifier = Modifier,
propagateMinConstraints: Boolean = false,
content: @Composable CircleBoxScope.() -> Unit
) {
var rotateDegree by remember {
mutableFloatStateOf(0F)
}
val measurePolicy = rememberBoxMeasurePolicy(Alignment.Center, propagateMinConstraints,rotateDegree)
Layout(
content = { CircleBoxScopeInstance.content() },
measurePolicy = measurePolicy,
modifier = modifier then Modifier.pointerInput("CircleBoxInputEvent"){
var startDegree = 0F
detectDragGestures { change, dragAmount ->
val dr = atan2(change.position.y.toDouble() - size.height/2f, change.position.x.toDouble() - size.width/2f);
var toFloat = (dr - startDegree).toFloat()
if(toFloat == Float.POSITIVE_INFINITY || toFloat == Float.NEGATIVE_INFINITY){
toFloat = 0F
}
rotateDegree += toFloat
startDegree = dr.toFloat();
}
}
)
}
@PublishedApi
@Composable
internal fun rememberBoxMeasurePolicy(
alignment: Alignment,
propagateMinConstraints: Boolean,
rotateDegree: Float //传入角度,支持旋转
) = remember(alignment, propagateMinConstraints,rotateDegree) {
circleBoxMeasurePolicy(alignment, propagateMinConstraints,rotateDegree)
}
//复写MeasurePolicy
internal class CircleBoxMeasurePolicy (
var alignment: Alignment,
var propagateMinConstraints: Boolean = false,
var rotateDegree: Float = 0F
): MeasurePolicy {
override fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult {
Log.d(TAG,"rotateDegree = $rotateDegree")
if (measurables.isEmpty()) {
return 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 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.
return layout(boxWidth, boxHeight) {
placeables.forEachIndexed { index, placeable ->
placeable as Placeable
val innerRadius = radius - max(placeable.height,placeable.width);
val x = cos(radian * index + rotateDegree) * innerRadius + boxWidth / 2F - placeable.width / 2F;
val y = sin(radian * index + rotateDegree) * innerRadius + boxHeight / 2F - placeable.height / 2F;
placeable.place(IntOffset(x.toInt(), y.toInt()))
}
}
}
}
//复写
internal fun circleBoxMeasurePolicy(
alignment: Alignment,
propagateMinConstraints: Boolean,
rotateDegree: Float
) =
CircleBoxMeasurePolicy(alignment,propagateMinConstraints,rotateDegree)
@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
}
以上是完整的代码实现,下面demo地址
附: Github源码
总结
以上就是本篇的核心内容,在这篇文章中我们可以了解到MeasurePolicy的用法和设计思想。目前而言,Compose UI有很多超前的设计。有很多大家喜欢的轮子官方都给造好了,所以我们可以放更多精力在状态控制和ViewModel上,提升开发效率。
说到提升开发效率,google的程序员理论上和大家一样,都是面向老板编程,因此,尽早入局Compose UI或者Flutter显然是必要的。