Android Compose利用MeasurePolicy实现环形菜单

前言

在之前的文章中,我们通过传统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
}
相关推荐
HerayChen几秒前
HbuildderX运行到手机或模拟器的Android App基座识别不到设备 mac
android·macos·智能手机
顾北川_野1 分钟前
Android 手机设备的OEM-unlock解锁 和 adb push文件
android·java
hairenjing11233 分钟前
在 Android 手机上从SD 卡恢复数据的 6 个有效应用程序
android·人工智能·windows·macos·智能手机
GIS程序媛—椰子16 分钟前
【Vue 全家桶】7、Vue UI组件库(更新中)
前端·vue.js
DogEgg_00122 分钟前
前端八股文(一)HTML 持续更新中。。。
前端·html
ZL不懂前端25 分钟前
Content Security Policy (CSP)
前端·javascript·面试
小黄人软件28 分钟前
android浏览器源码 可输入地址或关键词搜索 android studio 2024 可开发可改地址
android·ide·android studio
木舟100929 分钟前
ffmpeg重复回听音频流,时长叠加问题
前端
王大锤439139 分钟前
golang通用后台管理系统07(后台与若依前端对接)
开发语言·前端·golang
dj15402252031 小时前
group_concat配置影响程序出bug
android·bug