Jetpack Compose - Ticket Shape

Max Tayler
6 min readFeb 1, 2023

--

Target Outcome

At DICE we have recently migrated our ticket list screen to Jetpack Compose.

In order to match the current designs we needed to clip the content of our layout to a shape that incorporates rounded corners (easy enough, this shape already exists in the API) plus a semi circle “cut out” in the middle top & bottom of the layout, see example below:

Example DICE Ticket Card

Unfortunately nothing exists out of the box in the existing API, so we will need to draw a custom shape.

Custom Shape API

The Jetpack Compose API provides us with 2 methods of drawing a custom shape. Both leverage the Path interface to draw the required shape.

  1. Shape interface — extend this interface to create your own custom Shape class.
  2. GenericShape class — this class extends the Shape interface & requires a “builder” lambda as a constructor parameter. Within the lambda you have access to a Path object which can be used to draw the shape.

In this example I will be extending the shape interface so to have a standalone class to encapsulate all the logic we need to create the custom shape which can be reused across a codebase.

Defining Useful Properties

Firstly let’s take a look at the Shape interface.

/**
* Defines a generic shape.
*/
@Immutable
interface Shape {
/**
* Creates [Outline] of this shape for the given [size].
*
* @param size the size of the shape boundary.
* @param layoutDirection the current layout direction.
* @param density the current density of the screen.
*
* @return [Outline] of this shape for the given [size].
*/
fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline
}

Before we start drawing the path we can use the parameters provided by the createOutline function to define some important properties needed to draw the shape.

val middleX = size.width.div(other = 2)
val circleRadius = with(density) { 10.dp.toPx() }

Firstly we halve the width of the shape boundary to find the middle x coordinate. Secondly use the density to convert our desired circle radius to the equivalent pixel value.

Drawing the Shape Step-By-Step

  1. Reset the path to the origin point (x: 0, y: 0)

To achieve this we can simply use the reset function from the Path API.

Visual representation from the result of the reset function

The dot shown above won’t be part of our final shape it’s simply used as a representation of the origin point.

2. Draw horizontal line to top middle

To achieve this we can use the lineTo function passing the middleX property we defined earlier (minus the radius of our circle):

lineTo(x = middleX.minus(circleRadius), y = 0F)
Visual representation from the result of the lineTo function

3. Draw top semi circle

To achieve the semi circle cutout we can use the arcTo function. This is a little more complex than the other functions so I will try to explain it further below:

Firstly we need to provide a rectangle shape to define the bounding box of where the arc will be drawn.

val rect = Rect(
left = middleX.minus(circleRadius),
top = 0F.minus(circleRadius),
right = middleX.plus(circleRadius),
bottom = 0F.plus(circleRadius)
)

Using the same logic from step 2 we can ensure the left hand side boundary is in line with the line we drew. Additionally we can use middleX plus circleRadius to define the right hand side boundary — this will ensure the width of our bounding box is equal to the desired circle diameter.

From this point we can again use the circle radius to ensure the desired top and bottom boundaries of the box.

Visual representation of the bounding box

Now we must define the start and end angle of the arc to draw. I found using the below diagram can help to visualise what we would need here.

To draw our semi circle we will need to start at 180 degrees and sweep in an anti clockwise direction to 0 degrees (in other words -180 degrees). So we define the below parameters:

val startAngle = 180F
val sweepAngle = -180F

Pulling it all together will achieve arc displayed below

val rect = Rect(
left = middleX.minus(circleRadius),
top = 0F.minus(circleRadius),
right = middleX.plus(circleRadius),
bottom = circleRadius
)
val startAngle = 180F
val sweepAngle = -180F
arcTo(rect = rect, startAngleDegrees = startAngle, sweepAngleDegrees = sweepAngle, forceMoveTo = false)
Visual representation from the result of the arcTo function

4. Draw horizontal line to top right

lineTo(x = size.width, y = 0F)
Visual representation from the result of the lineTo function

5. Draw vertical line to bottom right

lineTo(x = size.width, y = size.height)
Visual representation from the result of the lineTo function

6. Draw a horizontal line to bottom middle

lineTo(middleX.plus(circleRadius), y = size.height)
Visual representation from the result of the lineTo function

7. Draw bottom semi circle

Using the same logic as before we can draw our bounding box + arc. The only difference is here we want to start at an angle of 0 degrees and sweep again in an anti clockwise direction to 180 degrees.

val rect = Rect(
left = middleX.minus(circleRadius),
top = size.height.minus(circleRadius),
right = middleX.plus(circleRadius),
bottom = size.height.plus(circleRadius)
)
val startAngle = 0F
val sweepAngle = -180F
arcTo(rect = rect, startAngleDegrees = startAngle, sweepAngleDegrees = sweepAngle, forceMoveTo = false)
Visual representation from the result of the arcTo function

8. Draw a horizontal line to bottom left

lineTo(x = 0F, y = size.height)
Visual representation from the result of the lineTo function

9. Close the path

To achieve this we can simply use the close function from the Path API.

Visual representation of the final shape

Combining Paths

As you may have noticed our final shape is missing the rounded corners from the original design. To achieve this in a simple way we can take advantage of the static combine function provided by Path.

Firstly create a rounded rectangle using the size provided by the createOutline function and then apply it to a new Path object:

val roundedRect = RoundRect(size.toRect(), CornerRadius(cornerSize.toPx(size, density)))
val roundedRectPath = Path().apply { addRoundRect(roundedRect) }

Then we can provide our 2 paths to the combine function along with a desired operation:

val roundedRect = RoundRect(size.toRect(), CornerRadius(cornerSize.toPx(size, density)))
val roundedRectPath = Path().apply { addRoundRect(roundedRect) }
Path.combine(operation = PathOperation.Intersect, path1 = roundedRectPath, path2 = getTicketPath(size, density))

Using PathOperation.Intersect will ensure we only keep the overlapping sections of both paths, in this case that will mean the square corners of the shape we drew earlier will get cut out as they will not overlap with the rounded rectangle.

Below is the final class:

Final TicketShape class

--

--

Max Tayler
Max Tayler

Written by Max Tayler

Senior Android Engineer @ DICE FM

Responses (3)