# Custom Shapes in Jetpack Compose

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

Working with custom shapes have 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 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 with 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 dimensions 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 radius of corner. The `modifier`

and `elevation`

parameters allow us to assign a size an 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 from 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 `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, centered text looks shifted towards the right corner. Moving it to 20% of width to 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 values of any two sides of a triangle or one side and angle, we can calculate the rest of them.

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

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

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

Angle alpha is equals to arctangent of ratio of 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 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 additional function of the `Path`

class.

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

To explain 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 start 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 - 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, 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 all together and calculate these values.

Before doing that we will need values of angles of 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 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 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 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 sum of angles of 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 in similar way as we did it for point Q. We can even 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 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 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 definition of 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 need to draw line to 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 starting angle. Since **ME** is perpendicular to tangent we can figure out that 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)
}
)
}
```

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.