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 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.

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 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.

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)
       }
   )
}

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.