Happy Employees == Happy ClientsCAREERS AT DEPT®
DEPT® Engineering BlogProcess

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.

How our final button should appear

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.

How our final button should appear

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.