In this article I will take a closer look at how to achieve custom drawing in Jetpack Compose using androidx.compose.ui.graphics.Shape
. Let's start with something really simple:
@Composable
fun RowWithThreeRectangles() {
Row(modifier = Modifier.fillMaxWidth()
) {
Rectangle(color = Color.Red)
Rectangle(modifier = Modifier.weight(0.3f)
.height(64.dp),
color = Color.Blue)
Rectangle(modifier = Modifier.weight(0.7f),
color = Color.Green)
}
}
Rectangle
receives a color and an optional modifier (which I use to control the size of the rectangle).
@Composable
fun Rectangle(color: Color,
modifier: Modifier = Modifier) {
Box(
modifier = modifier.composed {
preferredSize(32.dp)
.clip(RectangleShape)
.background(color)
}
)
}
It is just a Box
which is clipped to a shape. clip()
gets an instance of androidx.compose.ui.graphics.Shape
. This interface has just one funtion, createOutline()
, which receives two parameters:
- the size of the shape boundary
- the current density of the screen
It returns an instance of androidx.compose.ui.graphics.Outline
. This class defines a (more or less) simple shape, which is used for bounding graphical regions by
- defining a shape of the component background or a shape of shadows cast by the component
- clipping the contents
I will cover Outline
later. For now I stick to existing implementations of Shape
.
@Composable
fun RowWithThreeCircles() {
Row(modifier = Modifier.fillMaxWidth()
) {
Circle(color = Color.Red)
Circle(color = Color.Blue)
Circle(color = Color.Green)
}
}
@Composable
fun Circle(color: Color,
modifier: Modifier = Modifier) {
Box(
modifier = modifier.composed {
preferredSize(32.dp)
.clip(CircleShape)
.background(color)
}
)
}
The code is practically the same, only difference being that CircleShape
is used. In your app you would probably refactor this into one function and just pass the Shape
. CircleShape
, by the way, is not a class but a variable. Its type is RoundedCornerShape
, which extends abstract CornerBasedShape
. Another child of this class is CutCornerShape
. It produces a rectangle with cut corners. If you edit my Rectangle
like this
.clip(CutCornerShape(8.dp))
it produces this output:
But what about shapes that are not covered by builtin code? In such cases you can use GenericShape
. Take a look:
@Composable
fun RowWithATriangle() {
Row() {
Triangle(modifier = Modifier.fillMaxWidth(),
color = Color.Yellow)
}
}
I want the triangle to consume the full width, but leave its height at its preferred value.
@Composable
fun Triangle(color: Color,
modifier: Modifier = Modifier) {
Box(
modifier = modifier.composed {
preferredSize(64.dp)
.clip(GenericShape { size ->
moveTo(size.width / 2, 0f)
lineTo(size.width - 1, size.height - 1)
relativeLineTo(-size.width, 0f)
})
.background(color)
}
)
}
size
is the size of the composable the shape is applied to. You can access its width with size.width
and the height with size.height
. The shape is defined through some basic instructions. Initially painting will start at the top left corner. moveTo()
sets the current location of the pencil, whereas lineTo()
draws a line from that positon to a new one. Did you notice that I don't move back to the starting point? As you shall see shortly this is done automatically by invoking a function named close()
. You can check this if you add something like relativeLineTo(32f, -32f)
. In this case we no longer have a triangle, but something that looks like this:
Where do these drawing primitives (and close()
) come from? They are defined in the interface androidx.compose.ui.graphics.Path
. To see how it is used by Compose itself let's peek into GenericShape
.
So, GenericShape
implements createOutline()
, which is declared in Shape
, by
- applying the provided drawing instructions to a
Path
instance - calling
close()
- returning an instance of
Outline.Generic
Let's see where the implementation of Path
comes from.
Finally, what about Outline
? Basically, it wraps a Path
. It is not instantiated directly. Instead, you should use one of its nested classes Rectangle
, Rounded
or Generic
. Let's see how this works. To get this
I use two composables:
@Composable
fun RowWithADonut() {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center) {
Donut()
}
}
@Composable
fun Donut() {
Surface(modifier = Modifier.preferredSize(100.dp),
color = Color.Red,
shape = object : Shape {
override fun createOutline(size: Size, density: Density): Outline {
val thickness = size.height / 4
val p1 = Path().apply {
addOval(Rect(0f, 0f, size.width - 1, size.height - 1))
}
val p2 = Path().apply {
addOval(Rect(thickness,
thickness,
size.width - 1 - thickness,
size.height - 1 - thickness))
}
val p3 = Path()
p3.op(p1, p2, PathOperation.difference)
return Outline.Generic(p3)
}
}
) {
}
}
Let's see what is worth mentioning here. First of all I use Surface
, which gets the shape from shape
. The color is set with, well, color
. For the sake of simplicity I construct the shape in place. In your app you probably want to wrap it in a class, for example DonutShape
. createOutline()
does quite a few things. It creates three paths, one large and a smaller oval. The third path is the result of the difference of them. Think of it as cookie cutting. I remove the smaller one from the larger, which gives us a donut-like shape.
Have you worked with shapes and outlines, too? Please share your thoughts in the comments.
Top comments (1)
I continue with this topic in a small series called Drawing and painting in Compose