Compose Material3 adds new vertical separator (VerticalDivider) analysis and doubts

Foreword

Google released the Compose Material3 1.2.0-alpha04 version on July 28. In this version, two new components were added (modified), vertical separators and segmented buttons:

Experimental Segmented Button API.

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

This article will analyze the source code of the separator and explain a strange place I found when looking at the source code.

Text

Update content

Before we start, let me tell you a little story.

On the Android developer website, Google’s update record gives a different API for this new component than the final API…

Regarding the change of the separator, what is said in the change log is that a parameter has been added to Divider to specify whether the separator is a vertical separator. The same is true for the code in the commit record attached to the update record. written:

1.png

But when I updated my MD3 to this version, I found that Divider did not have the horizontal parameter. Instead, Divider was abandoned, and then Two new components have been added: VerticalDivider() and HorizontalDivider(). The former is the actual new vertical separator, and the latter is actually the Divider before the update. .

I thought I wrote the wrong version number, but I looked left and right and found that it was indeed correct. Then I checked compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Divider Modification history of .kt files:

2.png

Oh, the co-authorship has been changed again. It turns out that Google does the same thing, so it’s okay, hahaha.

Source code analysis

Before this Divider update, if we wanted to use Compose to display a vertical divider, we would usually write like this:

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

display effect:

3.png

What this code does is set the height of the Divider to fill the maximum height, and then set the width to 1 dp, which is the width of the divider.

In fact, as long as we look at the source code of Divider, we will find that using it this way is a bit “unnecessary”:

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

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

The implementation of Divider only defines a Box that fills the entire width, has a height of the border width set by the parameter, and a background color.

So if we need vertical separators, we only need to simply change the official source code:

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

Hey, guess what? The first version of the officially implemented vertical separator actually does this:

4.png

It’s just that the final version was changed to another implementation method, and horizontal segmentation and vertical segmentation were split into two different components. Even so, the core principles are actually the same:

VerticalDivider:

@Composable
funVerticalDivider(
    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),
    )
}

As you can see, the new implementation no longer uses Box to implement it, but instead creates a Canvas and uses drawLine in it.

The size of Canvas is the same as when using Box above. If it is split horizontally, then modifier.fillMaxWidth().height(thickness), if If it is vertical division, then modifier.fillMaxHeight().width(thickness).

Next, there is the confusing code that makes me puzzled. I don’t know if my level is too low to understand or if the code here is really confusing.

Here we take HorizontalDivider as an example. It draws a line in Canvas with a width of thickness, and the starting point is (0, half the line width). A line whose end point is (Canvas width, half line width).

Translated into human language, it uses drawLine to draw a line and fill the entire Canvas

Forehead? I personally understand that replacing Box with Canvas is because Canvas has better performance than Box, because in fact Compose UI rendering eventually returns to “drawing” using Canvas. Although the Canvas here is different from Android’s Canvas and Compose’s Canvas, I still understand it this way. .

So, why bother using line drawing to fill this Canvas?

Moreover, my above understanding is actually incorrect. Let’s take a look at the implementation of Canvas:

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

As you can see, this Canvas actually creates a new Composable function of Spacer, and then uses drawBehind to receive the incoming DrawScope.

In this case, wouldn’t it be enough to just use a simple Composable function to draw it?

If you don’t like Box, it’s not impossible to use 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")
 }
}

So why did the implementation code here suddenly change to this? Puzzled.

So I continued to check the submission record of this change:

5.png

Oh, so it was to fix a bug?

let me see:

6.png

Okay, excuse me, this is not a public issue…

It seems that this trouble can only stay with me like this (

One More Thing

When using vertical separators, if the parent component is Surface and the size is set to the maximum size:

@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!")
        }
    }
}

Then, it will become like this:

7.png

The separator fills the entire height!

Obviously, from our interpretation of the source code above, we can know that the separator is implemented by setting fillMaxXXX in the corresponding direction and then setting the other direction to the width of the separator.

Here, because our top-level component has fillMaxSize() set, when we add a separator, it will naturally expand to fill the entire screen.

However, if you look at this code carefully, you will find that we actually set Row(modifier = Modifier.height(IntrinsicSize.Min)) in its parent component. So why does this separator still appear? What about filling the entire screen?

This is because of 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.

In other words, the value we pass in here may be overwritten. If you want it not to be overwritten, you should use 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.

Using this modifier, the passed value will not be overwritten:

8.png

Therefore, the Row component will only use the minimum height, that is, the height of the separator will be consistent with the Text at the same level.

Summary

The above is my source code analysis of the newly added delimiter in MD3 and a confusing problem I found when viewing the source code. But at present, I still don’t know why Google designed it this way. If anyone knows, I hope they can enlighten me.