Basics of building a circular progress view

4 min read

There are many libraries out there of fancy circular loading views based on percentages and whatnot, but they do not always cover all the things developers and/or organization want them to do. Honestly, I never looked at the source code for the circular loading libraries, and over the past week due to a project I’m working on, wondered how it was done without using a library. This post will explain the starting point for a custom circular view.

The problem can be solved drawing arcs on a canvas arcs, instead of circles.

In general, an arc is any smooth curve joining two points. The length of an arc is known as its arc length. In a graph, a graph arc is an ordered pair of adjacent vertices. In particular, an arc is any portion (other than the entire curve) of the circumference of a circle.

Lets take a look at what we want on paper before beginning.

From the bad drawing above, we can see that we need 2 arcs. One to represent the normal, un-filled circle, and another inside of it to represent the filled up percentage. Lets start with the main arc.

We create a custom class that extends View and draw the parent arc. The arc that represents the circular view without any percentages displayed.

class CircularProgressView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {

    // required to draw the arcs
    private val rectF = RectF()

    // Used to draw pretty much anything on a canvas, which is what we will be drawing on
    private val paint = Paint().apply {
        // how we want the arcs to be draw, we want to make sure the arc centers are not colored
        // so we use a STROKE instead.
        style = Paint.Style.STROKE
    }

    // ...

    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)

        canvas?.let {
            rectF.apply {
                val width = (it.width.div(2)).toFloat() // center X of the canvas
                val height = (it.height.div(2)).toFloat() // center Y of the canvas

                // place the rectF it at the center of the screen with height and width of 200dp
                set(width - 200, height - 200, width + 200, height + 200)
            }

            // draw an arc that will represent an empty loading view
            it.drawArc(rectF, 0f, 360f, false, paint.apply {
                color = Color.GRAY

                // how wide the stroke should be, typically more than or equal to the strokeWidth
                // of the arc representing a filled percentage
                strokeWidth = 60f
            })

            // ...
        }
    }
}

The RectF is required in one of the Canvas#drawArc() overloaded methods.

RectF holds four float coordinates for a rectangle. The rectangle is represented by the coordinates of its 4 edges (left, top, right bottom). These fields can be accessed directly. Use width() and height() to retrieve the rectangle’s width and height. Note: most methods do not check to see that the coordinates are sorted correctly (i.e. left <= right and top <= bottom).

It is important that we realize that the style of the Paint object is set to Stroke. All Paint styles are straight forward, meaning that the center of the shape that the arc draws will not be filled up, only the borders will be shown.

By running this code alone, we will see a single arc drawn at the center of the screen.

Ok, so we know that an arc can pretty much draw pieces of a circle, in the example above we draw the circle, from 0 degrees (start point) to 360 (end point). We need to draw another arc on top of the existing arc with a different color (lets say green), to represent the percentage wanted. Normally, we think of percentages for anything going from 0% to 100%. So how can we do this when the arcs 100% is 360 degrees? With very simple math: fillPercentage = 360 (PERCENTAGE_WE_WANT / 100)

So if we wanted to draw a 25% fill to our existing arc, we would replace PERCENTAGE_WE_WANT with 25 to get the percentage for 360 degrees.

class CircularProgressView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {

    // ...

    // default percentage set to 0
    private var percentage = 0

    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)

        canvas?.let {
            // ...

            // get the actual percentage as a float
            val fillPercentage = (360 * (percentage / 100.0)).toFloat()

            // draw the arc that will represent the percentage filled up
            it.drawArc(rectF, 270f, fillPercentage, false, paint.apply {
                color = Color.GREEN // filled percentage color

                // how wide the stroke should be, typically less than or equal to the strokeWidth
                // of the empty arc
                strokeWidth = 30f
            })
        }
    }

    // ...
}

Thats pretty much it. The code to get started is extremely simple, but can be used to create complex circular loading views. I hope this helps.

Here is the full code for the CircularProgressView class. Full code with sample can be found HERE.

class CircularProgressView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {

    // required to draw the arcs
    private val rectF = RectF()

    // Used to draw pretty much anything on a canvas, which is what we will be drawing on
    private val paint = Paint().apply {
        // how we want the arcs to be draw, we want to make sure the arc centers are not colored
        // so we use a STROKE instead.
        style = Paint.Style.STROKE
    }

    // default percentage set to 0
    private var percentage = 0

    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)

        canvas?.let {
            rectF.apply {
                val width = (it.width.div(2)).toFloat() // center X of the canvas
                val height = (it.height.div(2)).toFloat() // center Y of the canvas

                // place the rectF it at the center of the screen with height and width of 200dp
                set(width - 200, height - 200, width + 200, height + 200)
            }

            // draw an arc that will represent an empty loading view
            it.drawArc(rectF, 0f, 360f, false, paint.apply {
                color = Color.GRAY

                // how wide the stroke should be, typically more than or equal to the strokeWidth
                // of the arc representing a filled percentage
                strokeWidth = 60f
            })

            // get the actual percentage as a float
            val fillPercentage = (360 * (percentage / 100.0)).toFloat()

            // draw the arc that will represent the percentage filled up
            it.drawArc(rectF, 270f, fillPercentage, false, paint.apply {
                color = Color.GREEN // filled percentage color

                // how wide the stroke should be, typically less than or equal to the strokeWidth
                // of the empty arc
                strokeWidth = 30f
            })
        }
    }

    fun setPercentage(percentage: Int) {
        this.percentage = percentage
        invalidate()
    }
}