Compose Material3 新增垂直分隔符(VerticalDivider)解析与疑惑

前言

谷歌在 7 月 28 日发布了 Compose Material3 1.2.0-alpha04 版本,在该版本新增(修改)了两个组件,垂直分隔符和分段按钮:

Experimental Segmented Button API.

Dividers now have a parameter to control orientation to support vertical dividers.

本文将解析分隔符的源码并阐述我在看源码时发现一个奇怪的地方。

正文

更新内容

在正式开始之前先说一个小插曲。

在 Android developer 网站上,谷歌的更新记录给出的这个新组件的 API 和最终发布的 API 不一样……

关于分隔符的变动,在更新日志中说的是为 Divider 添加了一个参数用于指定这个分隔符是否是垂直分隔符,更新记录附上的提交记录中的代码也是这样写的:

1

但是当我更新我的 MD3 到 这个版本时,却发现 Divider 并没有 horizontal 这个参数,取而代之的是 Divider 被废弃,然后新增了两个组件: VerticalDivider()HorizontalDivider() ,前者就是实际新增的垂直分隔符,而后者其实就是更新前的 Divider

我还以为我写错了版本号,但是我左看右看,确实没写错啊,后来去翻了一下 compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Divider.kt 文件的修改历史:

2

哦,合着是又改了啊,原来谷歌也会这样啊,那没事了,啊哈哈哈。

源码解析

在本次 Divider 更新前,如果我们想要使用 Compose 显示一个垂直的分隔线通常会这样写:

@Preview(backgroundColor = 0xFFFFFFFF, showBackground = true)
@Composable
fun PreviewTest() {
 Row(
     modifier = Modifier.height(IntrinsicSize.Min)
 ) {
     Text(text = "text1")
     Divider(
         modifier = Modifier.fillMaxHeight().width(1.dp)
     )
     Text(text = "text2")
 }
}

显示效果:

3

这段代码的意思是通过将 Divider 的高度设置为填充满最大高度,然后将宽度设置为 1 个 dp,也就是分隔线的宽度来实现的。

其实我们只要看一下 Divider 的源码,就会发现这样使用有点“多此一举”了:

@Composable
fun Divider(
    modifier: Modifier = Modifier,
    thickness: Dp = DividerDefaults.Thickness,
    color: Color = DividerDefaults.color,
) {
	// ……

    Box(
        modifier
            .fillMaxWidth()
            .height(targetThickness)
            .background(color = color)
    )
}

Divider 的实现也只是定义了一个填充满全部宽度,并且高度为参数设置的边框宽度且带有背景颜色的 Box 罢了。

所以如果我们需要垂直分隔符的话,只需要简单改一下官方源码即可:

@Composable
fun Divider(
    modifier: Modifier = Modifier,
    thickness: Dp = DividerDefaults.Thickness,
    color: Color = DividerDefaults.color,
) {
    // ……
    Box(
        modifier
            .fillMaxHeight()
            .width(targetThickness)
            .background(color = color)
    )
}

欸,你猜怎么着?官方实现的垂直分隔符第一版还真是这样做的:

4

只不过最终发布的版本改成了另外一种实现方式,并且还把水平分割和垂直分割拆分成了两个不同的组件,虽说如此,其实核心原理都差不多:

VerticalDivider:

@Composable
fun VerticalDivider(
    modifier: Modifier = Modifier,
    thickness: Dp = DividerDefaults.Thickness,
    color: Color = DividerDefaults.color,
) = Canvas(modifier.fillMaxHeight().width(thickness)) {
    drawLine(
        color = color,
        strokeWidth = thickness.toPx(),
        start = Offset(thickness.toPx() / 2, 0f),
        end = Offset(thickness.toPx() / 2, size.height),
    )
}

HorizontalDivider:

@Composable
fun HorizontalDivider(
    modifier: Modifier = Modifier,
    thickness: Dp = DividerDefaults.Thickness,
    color: Color = DividerDefaults.color,
) = Canvas(modifier.fillMaxWidth().height(thickness)) {
    drawLine(
        color = color,
        strokeWidth = thickness.toPx(),
        start = Offset(0f, thickness.toPx() / 2),
        end = Offset(size.width, thickness.toPx() / 2),
    )
}

可以看到,新的实现方式不再采用 Box 来实现,而是改用创建一个 Canvas 并在其中 drawLine 来实现。

Canvas 的尺寸则和上述使用 Box 时一样,如果是水平分割则 modifier.fillMaxWidth().height(thickness) ,如果是垂直分割则 modifier.fillMaxHeight().width(thickness)

接下来,就是让我百思不得其解的迷惑代码,咱也不知道是我层次太低无法理解还是这里的代码确实有点迷惑。

这里以 HorizontalDivider 举例,它在 Canvas 中画了一条宽度为 thickness,起点为 (0, 线条宽度二分之一),终点为 (Canvas 宽度, 线条宽度二分之一) 的线。

翻译成人话就是,它用 drawLine 画了一条线把整个 Canvas 填充了……

额?把 Box 换成 Canvas 我个人理解为是因为 Canvas 性能比 Box 好,因为事实上 Compose 的 UI 渲染最终也是回归到使用 Canvas “画”出来的,虽然这里的 Canvas 是 Android 的 Canvas 和 Compose 的 Canvas 不一样,但是我还是如此去理解了。

那么,为什么还要多此一举的使用画线的方式去填充满这个 Canvas 呢?

况且,实际上我上面的理解也是不正确的,我们看一下这个 Canvas 的实现:

@Composable
fun Canvas(modifier: Modifier, onDraw: DrawScope.() -> Unit) =
    Spacer(modifier.drawBehind(onDraw))

可以看到,这个 Canvas 实际上也是新建了一个 Spacer 的 Composable 函数,然后在其中使用 drawBehind 接收了传入的 DrawScope

既然如此,那么直接使用一个简单的 Composable 函数来画不就行了?

不喜欢 Box 那就用 Spacer 也不是不行:

@Preview(backgroundColor = 0xFFFFFFFF, showBackground = true)
@Composable
fun PreviewTest() {
 Row(
     modifier = Modifier.height(IntrinsicSize.Min)
 ) {
     Text(text = "text1")
     Spacer(modifier = Modifier.fillMaxHeight().width(1.dp).background(Color.Gray))
     Text(text = "text2")
 }
}

所以这里的实现代码后来为什么突然改成了这样呢?百思不得其解。

于是我又继续查看这次更改的提交记录:

5

哦,原来是为了修复某个 bug ?

让我看看:

6

好吧,打扰了,不是公开 issue ……

看来这个困扰只能这样一直伴随我了(

One More Thing

在使用垂直分割符时,如果父组件是 Surface 且设置了尺寸为最大尺寸:

@Preview(backgroundColor = 0xFFFFFFFF, showBackground = true)
@Composable
fun PreviewTest() {
    Surface(modifier = Modifier.fillMaxSize()) {
        Row(
            modifier = Modifier.height(IntrinsicSize.Min)
        ) {
            Text(text = "Hello equationl!")
            VerticalDivider()
            Text(text = "Hello again!")
        }
    }
}

那么,将会变成这样:

7

分隔符会填充满整个高度!

显然,通过上面我们对源码的解读,我们可以知道,分割符的实现就是通过在对应的方向上设置 fillMaxXXX 然后将另外一个方向设置为分隔符的宽度来实现的。

在这里,因为我们的顶层组件设置了 fillMaxSize(),所以当我们添加分隔符时,它自然会被扩展到充满整个屏幕。

但是,如果你仔细看这段代码,你会发现我们其实在它的父组件中设置了 Row(modifier = Modifier.height(IntrinsicSize.Min)) 那为什么这个分隔符还是会充满整个屏幕呢?

这是因为 Modifier.height

Declare the preferred height of the content to be the same as the min or max intrinsic height of the content. The incoming measurement Constraints may override this value, forcing the content to be either smaller or larger.

也就是说,我们这里传入的值可能会被覆盖,如果想让它不被覆盖,应该使用 Modifier.requiredHeight

Declare the height of the content to be exactly the same as the min or max intrinsic height of the content. The incoming measurement Constraints will not override this value. If the content intrinsic height does not satisfy the incoming Constraints, the parent layout will be reported a size coerced in the Constraints, and the position of the content will be automatically offset to be centered on the space assigned to the child by the parent layout under the assumption that Constraints were respected.

使用这个修饰符,传入的值就不会被覆盖:

8

从而 Row 组件会仅使用最小高度,也就是说,分隔符的高度将和其中同级的 Text 保持一致。

总结

以上就是我对 MD3 最新增加的分隔符的源码解析以及在查看源码时发现的一个令人迷惑的问题。但是目前我还是不知道为什么谷歌会这样设计,如果有大佬知道希望能不吝赐教。