Custom Pie Chart with Jetpack Compose in Android | Compose Canvas

Pie Charts are used in many Android applications to display a huge quantity of data in a simple and easy format. This is seen in applications where a huge quantity of data is to be handled. In this article, we will take a look at How to Create a Custom Pie Chart in Android using Jetpack Compose. You can watch our full video to get an idea about what we are going to do in this article.

We need to create a data class which will hold our data for the pie chart. This data class hold 4 values as follows:

  1. color : Color of individual slice of the chart
  2. value : Value of the each slice with respect to total values of the chart
  3. description : Any info you want to show with each slice
  4. isTapped : this variable will hold the information if any slice is clicked or not
data class PieChartData(
    val color: Color= getRandomColor(),
    val value: Int,
    val description: String,
    val isTapped: Boolean = false
)

Lets create a function to get random color for the slice

internal fun getRandomColor(): Color {
    return Color(
        red = (0..255).random(),
        blue =  (0..255).random(),
        green =  (0..255).random()
    )
}

now create a list of data for the charts and insert into a list

val dataPoints = listOf(
                PieChartData(
                    value = 29,
                    description = "Data 1"
                ),
                PieChartData(
                    value = 21,
                    description = "Data 2"
                ),
                PieChartData(
                    value = 32,
                    description = "Data 3"
                ),
                PieChartData(
                    value = 18,
                    description = "Data 4"
                ),
                PieChartData(
                    value = 12,
                    description = "Data 5"
                ),
                PieChartData(
                    value = 38,
                    description = "Data 6"
                ),
            )

Now we need to create some variables for our graph as follows:

  1. pieCenter : (Offset) -this will maintain the center of our graph in the canvas
  2. inputList : List<PieData> – create a fresh list from the input data which we will keep updating
  3. isCenterTapped : Boolean – to maintain tapped state on any slice
    var pieCenter by remember {
        mutableStateOf(Offset.Zero)
    }

    var inputList by remember {
        mutableStateOf(dataPoints)
    }
    var isCenterTapped by remember {
        mutableStateOf(false)
    }

    val textMeasurer = rememberTextMeasurer()

    val gapDegrees = 2f
    val numberOfGaps = dataPoints.size
    val remainingDegrees = 360f - (gapDegrees * numberOfGaps)

    val totalValue = dataPoints.sumOf {
        it.value
    }

    val anglePerValue = remainingDegrees / totalValue

now create canvas composable for our graph and add the pointerInput modifier with true as key. In pointerInput we will use the detectDragGestures lamda in which we get the offset in onTap lamda.

then get the tap angle from the canvas with help of atan2() function then converting it to angle in Degree

val angle = Math.toDegrees(
    atan2(
         offset.y - pieCenter.y,
         offset.x - pieCenter.x
         ).toDouble()
    )
    
//this angle can be +ve or -ve so we need to check both
val tapAngleInDegrees = if (angle < 0) angle + 360 else angle

Now we will check conditions if the user is tapped at center of the pie or at any particular slice. If use click on the center then we will expand all the slice and show the info of each slice around it otherwise show the touched slice in expanded state.

val centerClicked = if (tapAngleInDegrees < 90) {
    //means y -ve and x +ve region
    offset.x < pieCenter.x + innerRadius && offset.y < pieCenter.y + innerRadius
} else if (tapAngleInDegrees < 180) {
    //means y -ve and x -ve region
    offset.x > pieCenter.x - innerRadius && offset.y < pieCenter.y + innerRadius
} else if (tapAngleInDegrees < 270) {
    //means y +ve and x +ve region
    offset.x > pieCenter.x - innerRadius && offset.y > pieCenter.y - innerRadius
} else {
    //means y +ve and x +ve region
    offset.x < pieCenter.x + innerRadius && offset.y > pieCenter.y - innerRadius
}

Now we got the boolean variable that user has clicked center or not and based on this we will change the isTapped argument of each slice. If center clicked then we make it true for all slice other wise only true for the touched slice.

if (centerClicked) {
   //make all slice isTapped value to true
   inputList = inputList.map {
        it.copy(isTapped = !isCenterTapped)
   }
   isCenterTapped = !isCenterTapped
} else {
    //means we have clicked individual slice
    var currAngle = 0f
    inputList.forEach { pieChartInput ->

         currAngle += pieChartInput.value * anglePerValue
         if (tapAngleInDegrees < currAngle) {
            val description = pieChartInput.description
            inputList = inputList.map {
                  if (description == it.description) {
                      onSliceClick(it)
                      it.copy(isTapped = !it.isTapped)
                  } else {
                      it.copy(isTapped = false)
                  }
                }
                return@detectTapGestures
            }
          }
}

Now come to inside of canvas and define some other variables like width & height of canvas, radius of pie, start angle and style of Pie

val width = size.width
val height = size.height

val radius = width / 2

pieCenter = Offset(x = width / 2f, y = height / 2f)

var currentStartAngle = 0f

val donutStyle = Stroke(
    width = 100f,
    cap = StrokeCap.Round
)

Now we need to loop through the pie data and get value with each iteration. before that define the scale variable how much we want to scale each slice on touch. Create an angle for each slice as per its value because we need to draw the arc for each slice as per its angle. We will be using drawArc method which will take following arguments as input

  1. startAngle – from this angle the arc will start drawing
  2. sweepAngle – till this angle arc will be drawn
  3. useCenter – (true or false) see pic for more clarity
  4. size – set the width and height of the arc
  5. topLeft – this offset will set as coordinate for top left corner of arc
  6. style – here you can define the filled or strike style of the arc

Lets code for the arc and we will call drawArc function inside the scale function so as to scale the individual slice.

val scale = if (pieChartInput.isTapped) 0.78f else 0.75f
val angleToDraw = pieChartInput.value * anglePerValue
 
scale(scale) {
     drawArc(
         color = pieChartInput.color,
         startAngle = currentStartAngle, sweepAngle = angleToDraw, useCenter = true,
         size = Size(
             width = radius * 2f,
             height = radius * 2f
         ),
         topLeft = Offset(
             (width - radius * 2f) / 2f,
             (height - radius * 2f) / 2f
         ),
         //style = donutStyle
      )
      currentStartAngle += angleToDraw + gapDegrees
}

its time to draw the text on the slices, so for that we need to convert the value of each slice into percentage. We will be using native canvas to draw text. To draw text exactly at the center of the slice we need the center Offset of the slice. To get center Offset first we need to find the center angle b/w start to the end of slice.

//percentage value of each data point
val percentage = (pieChartInput.value / totalValue.toFloat() * 100).toInt()

//draw text inside the slice to show the percentage of data
drawContext.canvas.nativeCanvas.apply {
    //only show data if > 5%
    if (percentage > 5) {
        val midAngle = currentStartAngle - gapDegrees - angleToDraw / 2f
        val midOffSet = Offset(
            x = (cos(Math.toRadians(midAngle.toDouble())) * radius + pieCenter.x).toFloat(),
            y = (sin(Math.toRadians(midAngle.toDouble())) * radius + pieCenter.y).toFloat()
        )

        val xOffset = (midOffSet.x + pieCenter.x) / 2
        val yOffset = (midOffSet.y + pieCenter.y) / 2

        val centerOfSlice = Offset(xOffset, yOffset)

        //measure text if it have any style like font, size, letter spacing
        val textLayoutResult = textMeasurer.measure(
            text = "$percentage %"
        )
        val textWidth = textLayoutResult.size.width
        val textHeight = textLayoutResult.size.height

        drawText(
            textLayoutResult, color = Color.Black,
            topLeft = Offset(
                centerOfSlice.x - textWidth / 2,
                centerOfSlice.y - textHeight / 2
            )
        )

    }
}

Now we will draw text out side of the slice when clicked. For that we need rotate angle which will lay each text around the pie. We need to create a radius factor which will manage the distance of text from the center at all angle around the pie. then finally we will use call the drawText function inside the rotate function.

//angle for the text outside the slice
var rotateAngle = currentStartAngle - gapDegrees - angleToDraw / 2f - 90f
//how much distance from center you want to draw text outside the slice
var radiusFactor = .9f
if (rotateAngle > 90f) {
    rotateAngle = (rotateAngle + 180).mod(360f)
    //above 90 make text angle to negative
    radiusFactor = -.9f
}

//show text outside the pie when tapped on a slice
if (pieChartInput.isTapped) {
    rotate(rotateAngle) {
        drawContext.canvas.nativeCanvas.apply {
            drawText(
                "${pieChartInput.description}: ${pieChartInput.value}",
                pieCenter.x,
                pieCenter.y + radius.times(radiusFactor),
                Paint().apply {
                    textSize = 16.sp.toPx()
                    textAlign = Paint.Align.CENTER
                    color = Color.Black.toArgb()
                }
            )
        }
    }
}

Now at the end you can draw a circle at the center of pie to hide the center of pie

//circle at the center of pie
drawCircle(
   center = pieCenter,
   color = Color.White,
   radius = innerRadius
)

GitHub gist for this video code : here

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top