# Custom Shapes in Jetpack Compose

Learn how Jetpack Compose makes creating custom shapes in your UI with some basic trigonometry.

Working with custom shapes has been a pain to work with for a long time in Android development. Yes, Android provides some tools to customize views for developers, but I was never satisfied with the achieved result. Take the triangle button, for instance; a true Material Design triangle button has dynamic shadows and a triangle ripple effect while pressing. Despite several approaches and the ability to see something that resembled a triangle, there were still compromises with mimicking real shadows and rendering with an unacceptable rectangular ripple effect. Eventually, I gave up on the existing tooling.

Now, with the introduction of Jetpack Compose, developers' hands are freed, and they are allowed to customize views in different ways. By overriding the one-function interface I achieved everything I wanted with a minimum amount of code with some basic trigonometry. Let me share my experience with you in this article.

As you can see - it is a triangle with rounded corners. Corner ratios can be set up dynamically. It has real shadows, and they react to pressing. The ripple effect also does not exceed the shape of the button. The size can be of any dimension the UI requires.

In order to render the button, Compose has the `@Compose Button`

function. Among all the parameters, let’s take a closer look at `shape: Shape`

. As you might have guessed, we will provide an instance of the `Shape`

interface here. Despite this parameter having a default value, `RoundedCornerShape()`

is usually provided here as an argument with a radius of the corner. The `modifier`

and `elevation`

parameters allow us to assign a size and an elevation. This is what we have for now so far.

```
Button(
onClick = { },
modifier = Modifier.size(width = 40.dp, height = 30.dp),
shape = RoundedCornerShape(5.dp),
contentPadding = PaddingValues(4.dp),
elevation = ButtonDefaults.elevatedButtonElevation(defaultElevation = 6.dp)
) {
Text(
text = "+1",
fontSize = 8.sp
)
}
```

Of course, in order to customize the shape, we will implement `Shape`

interface by overriding its single function
`fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline`

. Here, we have a `size`

parameter, which will be provided to our implementation with every recomposition. We will utilize this argument in order to fit the view into this `size`

.

```
class TriangleShape : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
) = Outline.Generic(
Path().apply {
val x = size.width
val y = size.height
moveTo(0f, 0f)
lineTo(x, y / 2)
lineTo(0f, y)
}
)
}
```

`Outline`

here is the borders of the view that we want to customize. It is a sealed class with three classes extending it - `Rectangle`

, `Rounded`

, and `Generic`

. What we need to do is create an instance of `class Generic(val path: Path) : Outline()`

by passing an instance of `Path`

. This class actually contains a list of functions that allow us to draw everything we might want.

For now, let’s start with something relatively simple and just implement a rectangular button with no `roundRadius`

. As was mentioned before, will need implementation of the Shape interface. We will use `fun moveTo(x: Float, y: Float)`

and `fun lineTo(x: Float, y: Float)`

. x and y here are coordinates in pixels.

A couple of words regarding this new class. Again, we can easily access view width and height via `size`

argument. Here, we need first to land on the top left corner of the triangle, which is the zero point of the coordinate system, by invoking the `moveTo`

function. Then, we draw a line to the middle of the right edge of the view - `lineTo(x, y / 2)`

. Finally, we draw a line to the bottom left corner of the triangle - `lineTo(0f, y)`

. No need to connect the last point with the first - it will be done for us by drawing a line automatically.

Updated `Button`

function

```
Button(
onClick = { },
modifier = Modifier.size(width = 40.dp, height = 30.dp),
shape = TriangleShape(),
contentPadding = PaddingValues(4.dp),
elevation = ButtonDefaults.elevatedButtonElevation(defaultElevation = 6.dp)
) {
Text(
text = "+1".uppercase(),
modifier = Modifier.offset(x = -(40.0 * 1 / 5).roundRadiusToInt().dp),
fontSize = 8.sp
)
}
```

Here we simply pass a new instance of `TriangleShape`

as shape parameter. It’s worth mentioning that due to the peculiarity of the triangle form, the centered text shifts towards the right corner. Moving it to 20% of width to the left makes it look a bit better: `Modifier.offset(x = -(40.0 * 1 / 5).roundRadiusToInt().dp)`

.

In general, we are ready to implement more complicated shapes. Since the majority of our future calculations will involve the right triangle, let me refresh some basics of trigonometry.

A right triangle is described with 5 values - two legs(**x** and **y**), hypotenuse(**h**) and two angles opposite to legs(**alpha** and **beta**). If we know the values of any two sides of a triangle or one side and angle, we can calculate the rest of them.

The sine of an angle is the ratio of the opposite leg to the hypotenuse - **sin(alpha) = x / h**.

The Cosine of an angle is the ratio of the adjacent leg to the hypotenuse - **cos(alpha) = y / h**.

The tangent of an is the ratio of the opposite leg to adjacent - **tan(alpha) = x / y**.

Angle alpha is equal to the arctangent of the ratio of the opposite leg to adjacent - **alpha = atan(x/y)**.

Ok, now starts the tricky part. This is the scheme of the button. We need to draw the **△ABC** but with rounded corners, so the figure is contained in **DGPHKJE**.

First what we need here is the radius of the circle which will be at the corners of the button. Since we want it to be configurable by the developer let’s add an argument in our class.

```
class TriangleShape(private val roundRadius: Float) : Shape
```

Also let’s add top level value which will convert dp to pixels in MainActivity.

```
private val Dp.float: Float get() = this.value * getSystem().displayMetrics.density
```

Now we can pass desired roundRadius as parameter.

```
shape = TriangleShape(4.dp.float),
```

Besides two previous functions, we will use the additional function of the `Path`

class.

```
fun arcToRad (
oval: Rect,
startAngleRadians: Float,
sweepAngleRadians: Float,
forceMoveTo: Boolean
)
```

To explain the meaning of these parameters, let’s take a close look at the right angle, for example.

`oval: Rect`

- In our case, an oval is a circle into which a square is inscribed. There are different methods on how to init Rect object, but we will use`fun Rect(center: Offset, radius: Float)`

In our case,`center`

is the coordinates of point N and`radius`

is a`roundRadius`

argument.`startAngleRadians: Float`

- Compose considers point**P**as the starting point for describing angles with clockwise direction. For instance - 6 o’clock - is**𝜋 / 2**radians, 9 o’clock -**𝜋**radians and 12 o’clock - is**-𝜋 / 2**. Of course, 3 o’clock is 0 radians. In our case - the negative value of angle**GNP**.`sweepAngleRadians: Float`

- is how large the arc is. Value of angle**GNH**for our example`forceMoveTo: Boolean`

is always`false`

.

So, the information we need to draw this shape - Coordinates of points **D**, **G**, **K**, **E**, **M**, **N**, **O** and angles **∠GNH**, **∠KOJ** and **∠EMD**.

Let’s wrap it all together and calculate these values.

Before doing that, we will need values of angles of the triangle - **∠EAD** and **∠GCH**. Values of **∠EAD** and **∠KBJ** are equal since the triangle is isosceles. Triangle **△ACR** is a right triangle and we know it sides (**width** and **height / 2**) it is possible to easily calculate angle **∠EAD** using `fun atan(x: Float): Float`

**tan(∠EAD) = RC / AR**=>

**∠EAD = atan(RC / AR)**

In the code, I will deliberately use article notation so it could be easier to follow the logic

```
val RC = size.width
val AR = size.height / 2
val DAE = atan(CR / AR)
```

For the next part, here is the top left corner with additional segments for calculations.

Here we need to calculate coordinates of point **D**. To do it, we need to calculate **AQ** and **QD** of **△AQD**. What else do we know about this triangle? We can calculate the value of **∠DAQ**. Since **∠EAQ** is right

**∠DAQ = 𝜋 / 2 - ∠EAD**

If we have known the value of hypotenuse **AD**, the rest of the calculations are trivial. Let’s take a look at another **△ADM**. We know it’s leg **DM** - it is the `roundRadius`

argument. We also know that the center of a circle inscribed in an angle lies on the bisector. Consequently:

**∠DAM = ∠EAD / 2**,

**tan(∠DAM) = MD / AD**=>

**AD = MD / tan(∠DAM)**

```
val DAQ = PI.toFloat() / 2 - DAE
val DAM = DAE / 2
val AD = roundRadius / tan(DAM)
```

Now we are ready to calculate coordinates of **D**

**sin(∠DAQ) = DQ / AD**=>

**DQ = AD * sin(∠DAQ)**

**cos(∠DAQ) = AQ / AD**=>

**AQ = AD * cos(∠DAQ)**

```
val DQ = AD * sin(DAQ)
val AQ = AD * cos(DAQ)
```

Finally, we are ready to use the first `Path`

function.

```
moveTo(AQ, DQ)
```

Let’s take a look at the right side of the triangle.

Next, we need to draw a line to point G. We already know the coordinates of point **C**. We can derive coordinates of point **G** by subtracting **CT** and **GT** from **x** and **y** of point **C** respectively. These values are legs of the right **△CGT**.

As in the previous example, we can calculate the legs of the right triangle if we know one of its angles and hypotenuse - **∠GCN** and **CG** in our case.

**∠GCN** is a half of **∠GCH**. Since we know that the sum of angles of a triangle is **𝜋** radians and our triangle is isosceles, and basis angles are equal to **DAE**

**∠GCN = (𝜋 - 2 * DAE) / 2**

**CG** is also a leg of another right **△CGN** with known **∠GCN** and leg - `roundRadius`

argument or **GN**.

**tan(∠GCN) = GN / CG**=>

**CG = GN / tan(∠GCN)**

Once we know **CG** we can calculate **GT** and **CT**

**sin(∠GCN) = GT / CG**=>

**GT = CG * sin(∠GCN)**

**cos(∠GCN) = CT / CG**=>

**CT = CG * cos(∠GCN)**

Coordinates of point **G**: **CR** - **CT**, **AR** - **GT**

```
val GCN = (PI.toFloat() - 2 * DAE) / 2
val CG = roundRadius / tan(GCN)
val GT = CG * sin(GCN)
val CT = CG * cos(GCN)
lineTo(CR - CT, AR - GT)
```

Now we have to draw an arc. Here, we need the center of the circle - coordinates of **N** and two angles - **∠CNG** and **∠GNH**. Since it is one of angle of right triangle **△CNG** and other angle is known,

**∠CNG = 𝜋 / 2 - ∠GCN**

**∠GNH = 2 * ∠CNG**

For point **N** we know y coordinate. Let’s now calculate **CN**.

**sin(∠GCN) = GN / CN**=>

**CN = GN / sin(∠GCN)**

Now we can draw the arc.

```
val CN = roundRadius / sin(GCN)
val CNG = PI.toFloat() / 2 - GCN
arcToRad(Rect(Offset(CR - CN, AR), roundRadius), -CNG, 2 * CNG, false)
```

The coordinates of point K are calculated similarly as we did for point Q. We can reuse precalculated values and apply them here.

```
val AB = size.height
lineTo(AQ, AB - DQ)
```

Moving next to the bottom left of the triangle.

We also need coordinates of point **O** and two angles for **arc** function - **∠KOU** and **∠JOK.** In order to calculate **BV** and **OV** as **x** and **y** coordinates, we need to know hypotenuse **BO** and value of **∠OBV** for right **△BOV**. It is worth noticing that **∠OBV** is equal to the sum of **∠DAM** and **∠DAQ** because it lies on the opposite side of the isosceles triangle. Also, admit that hypotenuse **BO** for the **△BVR** is also hypotenuse for **△BKO**. Leg **KO** and angle **∠KBO** are equal to `roundRadius`

argument and **∠DAM** angle. Let’s calculate **BO** within sine of angle as we did it before.

**sin(∠KBO) = KO / BO**=>

**BO = KO / sin(∠KBO) = KO / sin(∠DAM)**

Now calculating **BR** and **RO** could be done via sine and cosine.

**sin(∠OBV) = BR / BO**=>

**BR = sin(∠OBV) * BO**

**cos(∠OBV) = RO / BO**=>

**RO = cos(∠OBV) * BO**

Now we need to calculate **∠KOU**. **∠UOV** is right. That means

**∠KOU = 𝜋 / 2 - ∠KOV**

We can easily find **∠KOV** by subtracting **∠BOV** from **∠BOK**. Both of them can be calculated.

Consider the right **△BOV** first. Here, **∠BOV = 𝜋 / 2 - ∠OBV**. And in the same way we can easily calculate for **△BOK**.

**∠BOK = 𝜋 / 2 - ∠KBO = 𝜋 / 2 - ∠DAM**

Eventually,

**∠KOV = ∠BOK - ∠BOV**

**∠KOU = 𝜋 / 2 - ∠KOV**

Also, let’s calculate **∠JOK**. We know that the sum of angles of any rhombus is equal to **2 * 𝜋**. We also know that in the kite **BJOK**, there are two right angles **∠BJO** and **∠BKO** by the definition of the circle inscribed in an angle. Also we know **∠JBK** - it is equal to **∠DAE**.

**∠JOK = 2 * 𝜋 - 𝜋 / 2 - 𝜋 / 2 - ∠DAE = 𝜋 - ∠DAE**

Now we have all the information for drawing rect.

```
val OBR = DAM + DAQ
val BO = roundRadius / sin(DAM)
val BR = BO * cos(OBR)
val RO = BO * sin(OBR)
val BOR = PI.toFloat() - OBR
val BOK = PI.toFloat() - DAM
val KOR = BOK - BOR
val KOU = PI.toFloat() / 2 - KOR
val JOK = PI.toFloat() - DAE
arcToRad(Rect(Offset(BR, AB - RO), roundRadius), KOU, JOK, false)
```

Finally we have to draw left top arc.

We have everything that needs to draw a line to the top left corner.

**AE** is equal to precalculated **AD**.

```
lineTo(0f, AD)
```

The coordinates of point M can be calculated within **BR** and **RO** segments. We also need a starting angle. Since **ME** is perpendicular to the tangent, we can figure out that the initial angle is **𝜋**. Sweeping angle was calculated before and it is equal to **∠JOK**.

```
arcToRad(Rect(Offset(BR, RO), roundRadius), PI.toFloat(), JOK, false)
```

And this is our final result.

And final code.

```
class TriangleShape(private val roundRadius: Float) : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
) = Outline.Generic(
Path().apply {
val CR = size.width
val AR = size.height / 2
val DAE = atan(CR / AR)
val DAQ = PI.toFloat() / 2 - DAE
val DAM = DAE / 2
val AD = roundRadius / tan(DAM)
val DQ = AD * sin(DAQ)
val AQ = AD * cos(DAQ)
//move to point D
moveTo(AQ, DQ)
val GCN = (PI.toFloat() - 2 * DAE) / 2
val CG = roundRadius / tan(GCN)
val GT = CG * sin(GCN)
val CT = CG * cos(GCN)
// line to point G
lineTo(CR - CT, AR - GT)
val CN = roundRadius / sin(GCN)
val CNG = PI.toFloat() / 2 - GCN
// right arc
arcToRad(Rect(Offset(CR - CN, AR), roundRadius), -CNG, 2 * CNG, false)
val AB = size.height
// line to point K
lineTo(AQ, AB - DQ)
val OBV = DAM + DAQ
val BO = roundRadius / sin(DAM)
val BV = BO * cos(OBV)
val OV = BO * sin(OBV)
val BOV = PI.toFloat() - OBV
val BOK = PI.toFloat() - DAM
val KOV = BOK - BOV
val KOU = PI.toFloat() / 2 - KOV
val JOK = PI.toFloat() - DAE
// bottom left arc
arcToRad(Rect(Offset(BV, AB - OV), roundRadius), KOU, JOK, false)
// line to point E
lineTo(0f, AD)
// top left arc
arcToRad(Rect(Offset(BV, OV), roundRadius), PI.toFloat(), JOK, false)
}
)
}
```

The final blueprint with all auxiliary segments.

Despite the overwhelming amount of calculations they are all pretty simple trigonometric and geometrical rules. Defining any other shapes won’t be a big issue once it is sorted out for a triangle.

Link to Github. Commits are for every step we have done. I deliberately didn’t simplify calculations or unite any steps to make it as clear as possible. Feel free to reuse this code in the way that fits you best or contact me directly if any questions still arise.