Recently, while working with rounded rectangles, I encountered an issue. When the width of the widget is too small, smaller than the diameter of the rounded corner, Compose applies some special handling, scaling down the corner radius proportionally, so it still appears as a rounded rectangle, as shown in the image below.

In some cases, such as the scenario mentioned above, this approach may not be ideal. Instead, we would prefer to keep the corner radius constant, as shown here.

The content of this article is about how to achieve the above effect.

Initially, I intended to draw directly using the Canvas, but I realized that this approach isn’t versatile enough. In Compose, shapes are typically described using the Shape class, and the scenario above also falls into the category of rounded rectangle shapes. If I can define a Shape that can address the aforementioned situation, it can be applied in many places.

Compose provides a Shape interface, which includes a single method. When we create a custom Shape, our primary task is to implement this method.

@Immutable
interface Shape {
/**
* Creates [Outline] of this shape for the given [size].
*
* @param size the size of the shape boundary.
* @param layoutDirection the current layout direction.
* @param density the current density of the screen.
*
* @return [Outline] of this shape for the given [size].
*/
fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline
}

The input parameters represent the current widget’s information, where size is the widget’s dimensions, and the return value is an Outline.

When creating an Outline, we can refer to the existing RoundedCornerShape.

override fun createOutline(
size: Size,
topStart: Float,
topEnd: Float,
bottomEnd: Float,
bottomStart: Float,
layoutDirection: LayoutDirection
) = if (topStart + topEnd + bottomEnd + bottomStart == 0.0f) {
Outline.Rectangle(size.toRect())
} else {
Outline.Rounded(
RoundRect(
rect = size.toRect(),
topLeft = CornerRadius(if (layoutDirection == Ltr) topStart else topEnd),
topRight = CornerRadius(if (layoutDirection == Ltr) topEnd else topStart),
bottomRight = CornerRadius(if (layoutDirection == Ltr) bottomEnd else bottomStart),
bottomLeft = CornerRadius(if (layoutDirection == Ltr) bottomStart else bottomEnd)
)
)
}

We need to handle the special case of RoundedCornerShape, that is, when the width is smaller than the diameter of the corner radius. Based on this, we need to add a conditional branch to handle this scenario.

if (topStart + topEnd + bottomEnd + bottomStart == 0.0f) {
Outline.Rectangle(size.toRect())
} else if (topStart == bottomStart && size.width < (topStart * 2F)) {
...
} else {
Outline.Rounded(
RoundRect(
rect = size.toRect(),
topLeft = CornerRadius(if (layoutDirection == Ltr) topStart else topEnd),
topRight = CornerRadius(if (layoutDirection == Ltr) topEnd else topStart),
bottomRight = CornerRadius(if (layoutDirection == Ltr) bottomEnd else bottomStart),
bottomLeft = CornerRadius(if (layoutDirection == Ltr) bottomStart else bottomEnd)
)
)
}

For the sake of simplicity, we will only handle the case where topState is equal to bottomStart.

The newly added if branch represents the scenario we want to address.

This scenario includes two sub-cases:

  1. The widget’s height is less than or equal to the corner diameter. In this situation, there is only one semi-circle on the left side.

2. The widget’s height is greater than the corner diameter. In this case, the left side consists of two corner arcs and a connecting straight line.

To implement these two sub-cases, we can build different Paths.

val radius = topStart
val path = Path()
if (height > radius * 2) {
buildSlickRoundCornerPath(path, size, radius)
} else {
buildSingleArcPath(path, size, radius)
}
Outline.Generic(path)

The first case is relatively easy to handle.

private fun buildSingleArcPath(path: Path, size: Size, radius: Float) {
path.moveTo(size.width, 0F)
path.arcTo(
rect = Rect(0F, 0F, radius * 2F, radius * 2F),
startAngleDegrees = 90F,
sweepAngleDegrees = 180F,
forceMoveTo = true,
)
path.close()
}

The logic is quite simple. First, move to the top-right corner of the widget, then draw a circular arc with the radius equal to the corner radius, and finally close the Path.

Now let’s take a look at the second case.

The major challenge here is adjusting the height of the widget’s drawing area dynamically based on its width.

In the illustration, the yellow line segment represents the width of the widget. In this case, we need to calculate the length of the red line, and then draw the circular arc for that portion.

val arcHeight = sqrt(radius * radius - (radius - width) * (radius - width))

The distance from the circular arc to the top and bottom of the widget can be calculated as:

val yOffset = radius - arcHeight

Thus, the upper half of the circular arc can be drawn as follows:

path.arcTo(
rect = Rect(
left = 0F,
top = yOffset,
right = width * 2F,
bottom = yOffset + arcHeight * 2F,
),
startAngleDegrees = 180F,
sweepAngleDegrees = 90F,
forceMoveTo = true,
)

Next, we move the Path to the bottom of the lower circular arc:

val bottomArcBottom = height - yOffset
path.lineTo(x = width, y = bottomArcBottom)
path.arcTo(
rect = Rect(
left = 0F,
top = bottomArcBottom - arcHeight * 2,
right = width * 2F,
bottom = bottomArcBottom,
),
startAngleDegrees = 90F,
sweepAngleDegrees = 90F,
forceMoveTo = true,
)

Since the upper and lower arcs have the same size and similar parameters, there is no need for a detailed explanation.

Finally, close the Path.

path.lineTo(0F, yOffset + arcHeight)
path.close()

Alright, that’s all for this article. The implementation is relatively straightforward overall, but there are a few small details to pay attention to. Therefore, I decided to share it directly so that anyone encountering this issue can use it as a reference.

Since Compose was developed later, the ecosystem isn’t fully mature yet. When dealing with complex requirements, many small issues like this may arise. Things that could be quickly implemented using Views might require more time in Compose. Thus, it’s up to us developers to gradually improve and contribute to the Compose community.

Click here to view the complete code.

Source link