DEV Community

Cover image for Custom view from scratch. Part II. Canvas.
Eugene Zubkov
Eugene Zubkov

Posted on

Custom view from scratch. Part II. Canvas.

In Part I, I covered the basics of Custom View development. This time I want to tell you in detail how you can render a chart. Reach out for your artistic toolkit, and let’s add some detail and shade to your sketch!

Image description

This is our goal. Impressive, huh?
Image description


For starters, we’ll define the logical structure of our View. After that, we’ll move on to rendering, and look closer at each layer. I’ll show you how to debug the View and profile GPU rendering. And then — finally! — we’ll launch our app for the first time.

The logic of View

Image description

The View contains two objects — DataProvider and ChartRenderer. These two objects allow us to customise and test the chart flexibly. For example, you can implement a test configuration by using DataProvider with predefined values.

The DataProvider interface is presented by the two methods:

getHistoryData — gets the initial set of data,

observeNewData — subscribes to updates that determine how our chart will move.

While the ChartView is responsible for display and animation, the ChartRenderer contains the logic for chart drawing.

class ChartView @JvmOverloads constructor(
        context: Context,
        attrs: AttributeSet? = null,
        defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
    lateinit var dataProvider: DataProvider
    lateinit var chartRenderer: ChartRenderer
    private var disposable: Disposable?

    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        chartRenderer.init(dataProvider.getHistoryData(15))
        disposable = dataProvider.observeNewData((1000 / SPEED).toLong())
                .subscribe(this::addValue)
    }

    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        disposable?.dispose()
    }

    fun addValue(value: Double) {

    } 
}
Enter fullscreen mode Exit fullscreen mode

As you can see, we subscribe to changes in onAttachedToWindow and unsubscribe in onDetachedFromWindow. Of course, you may argue whether it’s reasonable to implement this logic inside the View. At Revolut, we do so for the sake of simplicity and clarity.

observeNewData in this example means Observable RxJava, but you may substitute it with any other technology used in your project.

If you want to publish your View, remember these key points:

  • Make sure you remove dependencies on third-party libraries.
  • Remove unnecessary logic. You can eliminate DataProvider as an entity because the View isn’t responsible for it. The end user should call the addValue, setValue and other methods instead, depending on the chart type.

Now let’s move on to the next step — rendering on Canvas.

Rendering on Canvas

First I have to say a few words about hardware acceleration here, and show you the coordinates grid. Then I’ll describe how layers are drawn. We’ll take a look at each layer separately — the background, the chart, the state marker, and the text.

Hardware acceleration. It’s 2023, and we‘ve forgotten about Android 4.4 now, right? This means that there should be no obstacles in the way of supporting hardware acceleration. However, this isn’t completely true. You still have to check with the Android documentation and see which functionality is supported when you apply acceleration.

Image description

The documentation contains a more detailed table than this one. As you can see, some capabilities are available only for Android 9 (28), and some are not available at all. For example, unfortunately drawVertices is unavailable, and it’s the only way to draw polygons without using Path.

Coordinates grid. This is another important thing that you really need to remember — the starting point is in the top left corner. Even I still forget about it sometimes!

Image description

Drawing layers (onDraw)

fun onDraw(canvas: Canvas) {
    canvas.drawPaint(backgroundPaint)

    stars.forEach { it.draw(canvas, skyPaint) }

    chartRenderer.draw(canvas, markerDotX, canvas.height.toFloat())

    drawCurrentValueMarker(canvas)
}
Enter fullscreen mode Exit fullscreen mode

This is how the onDraw method works. It draws all the View layers one by one.

drawPaint — fills our Canvas with the colour defined in Paint.

Next, onDraw renders the sky (background) and the chart and finishes with the current state marker and text.

The background (drawCircle)

To draw each circle in the background, call the drawCircle method from Canvas.

canvas.drawCircle(x, y, radius, paint)
Enter fullscreen mode Exit fullscreen mode

The parameters x, y, radius define the size and position of the circle. The Paint parameter defines the style, namely the colour in our case.

val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
    color = Color.WHITE
    alpha = 20
}
Enter fullscreen mode Exit fullscreen mode

You can optimise this part. If it suits your purpose for all your circles to have the same radius, you can use a primitive.

canvas.drawPoint(x, y, paint)
Enter fullscreen mode Exit fullscreen mode

However, the dots are square by default, being 1px in size. To fix this, let’s write a separate sky Paint.

val skyPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
    color = Color.WHITE
    alpha = 20
    strokeWidth = radius * 2
    strokeCap = Paint.Cap.ROUND
}
Enter fullscreen mode Exit fullscreen mode

Note that the strokeWidth defines the dot diameter.

Define the Paint parameter outside of the onDraw method to make sure you don’t put too much load on the Garbage Collector (GC).


The next thing we want to make is a loopable background without creating new objects every time. Let’s apply some simple mechanics: when the circle has reached beyond the screen, we just place it back on screen in a random place within the red area. The circle always moves to the left. Thanks to this little lifehack, it looks as if the background is infinite.

Image description

interface Entity {
    fun onFrame(frameTimeMillis: Long, chartSpeed: Int)

    fun draw(canvas: Canvas, paint: Paint)
}

class SkyEntity(private val parent: View) : Entity {
    private var x = -1f
    private var y = 0f
    private var radius = 0f
    private var speed = 0f

    init {
        generateNewValues(true)
    }

    private fun generateNewValues(initial: Boolean) {
        speed = 6.dpToPx(parent) * (4 + Random.nextFloat())
        radius = 3.dpToPx(parent) * (1 + Random.nextFloat())

        val dx = if (initial) 0 else 1
        x = parent.width * (dx + Random.nextFloat())
        y = parent.height * Random.nextFloat()
    }

    override fun onFrame(frameTimeMillis: Long, chartSpeed: Int) {
        if (x < -radius) {
            generateNewValues(false)
        }
        x -= speed * (frameTimeMillis / 1_000f) * chartSpeed
    }

    override fun draw(canvas: Canvas, paint: Paint) {
        canvas.drawCircle(x, y, radius, paint)
    }
}
Enter fullscreen mode Exit fullscreen mode

The chart line (ChartRenderer)

interface ChartRenderer {
    fun setValues(values: List<Double>)

    fun addValue(value: Double)

    fun onFrame(frameTimeMillis: Long, speed: Int)

    fun draw(canvas: Canvas, clipWidth: Int, clipHeight: Int)

    fun getChartItemByScreenX(x: Float): ChartItem
}

data class ChartItem(val y: Float, val value: Double)
Enter fullscreen mode Exit fullscreen mode

The ChartRenderer interface uses 4 methods:

  • setValues — sets values to create the chart
  • onFrame — is called before rendering each frame
  • draw — draws the chart
  • getChartItemByScreenX — allows us to define the point on our chart and its value for the marker based on the X coordinate.

Now we’ll draw a chart using Paths. This process is iterative.

canvas.clipRect(0f, 0f, clipWidth, clipHeight)
canvas.translate(-offset, 0f)

canvas.drawPath(chartBackgroundPath, fillPaint)
canvas.drawPath(chartLinePath, linePaint)
Enter fullscreen mode Exit fullscreen mode

The chart’s line and fill are each defined by a Path. There are several Paint styles you need to know:

  • STROKE is used to draw a line
  • FILL is used to colour an item0
  • FILL_AND_STROKE won’t draw a line with colour, as you might have expected. It’s a FILL style that takes into account the strokeWidth value

clipRect cuts the chart to the required width.


Now let’s smooth our step chart. For the line to run smoothly from one value to another, we need to make the line curvy. There are at least two ways to draw a curve in Path. The Bezier curve is the first and most flexible way to do it. Our second option could be a pathEffect parameter — or CornerPathEffect in our case — where we can specify the radius of the rounded angles.

paint.pathEffect = CornerPathEffect(10)
Enter fullscreen mode Exit fullscreen mode

We use the Bezier curve, as it’s the most appropriate for our needs. Later, I’ll show you why the second option won’t work. To create the chart using the Bezier curve, you just need to write a small piece of simple code.

values.reduce { previous, current ->
    val deltaHeight = previous.toChartY() - current.toChartY()

    chartLinePath.rCubicTo((batchWidth * LINE_CURVE_FACTOR), 0f,
                           batchWidth - (batchWidth * LINE_CURVE_FACTOR), deltaHeight,
                           batchWidth, deltaHeight)

    current
}
Enter fullscreen mode Exit fullscreen mode

Let’s take a closer look at the rCubicTo method. It uses 3 pairs of coordinates as the input — the first two pairs are coordinates of control points, and the third pair are the coordinates of the end point in the curved line. batchWidth — is a step in the graph, LINE_CURVE_FACTOR — is the extent of the smoothing (e.g. 0.5).

Now let’s see how smoothing works.

Image description

P0 is the starting point of the curve, Р3 is the end point. Р1 and Р2 are control points — you can move them to make the curve look how you want. You don’t have to provide absolute values here: ‘r’ in rCubicTo stands for ‘relative’, so the chart moves in relation to the previous coordinate values. Thus rCubicTo allows you to leave the starting point value undefined, because it can use the end point from the previous iteration.

override fun draw(canvas: Canvas, clipWidth: Float, clipHeight: Float) {
    canvas.save()

    canvas.clipRect(0f, 0f, clipWidth, clipHeight)
    canvas.translate(-offset, 0f)

    canvas.drawPath(chartBackgroundPath, fillPaint)
    canvas.drawPath(chartLinePath, linePaint)

    canvas.restore()
}

//invoked with a new data
private fun recreatePath() {
    val bottom = parent.height - parent.paddingBottom.toFloat()
    chartLinePath.rewind()
    chartLinePath.moveTo(-batchWidth, bottom)
    chartLinePath.rMoveTo(0f, -values.first().toChartY())

    values.reduce { previous, current ->
        val deltaHeight = previous.toChartY() - current.toChartY()

        chartLinePath.rCubicTo((batchWidth * LINE_CURVE_FACTOR), 0f,
                batchWidth - (batchWidth * LINE_CURVE_FACTOR), deltaHeight,
                batchWidth, deltaHeight)

        current
    }

    pathMeasure.setPath(chartLinePath, false)

    chartBackgroundPath.set(chartLinePath)
    chartBackgroundPath.rLineTo(0f, parent.height.toFloat())
    chartBackgroundPath.rLineTo(-batchWidth * (values.size - 1), 0f)
    chartBackgroundPath.close()
}
Enter fullscreen mode Exit fullscreen mode

Let’s take a look at the chart now.

Image description

Okay, so what’s left?

  1. The current state marker
  2. The text

The marker

Since the marker must follow the chart change, we need to be able to determine the Y coordinate based on the constant X coordinate. To determine a point on an open Path, we’ll use PathMeasure. We need to specify our Path to initialise it.

paintMeasure.setPath(chartLinePath, false)
boolean getPosTab(float distance, float pos[], float tan[])
Enter fullscreen mode Exit fullscreen mode

To determine the position, we call the method getPosTan — it accepts three parameters as input:

  • Distance — is the length of the contour from the beginning to our point.
  • Pos / Tan — are arrays where data about the point (its position and tangent) will be written. In our case, we can omit the third parameter.

So how do we get a point on the chart based on the screen coordinate X? Let’s try the binary search.

You might think that we can calculate the coordinate using the formula, because we know the following three factors:

  1. How we build the curve
  2. That we don’t have loops
  3. The coordinates of all chart segments

However, it’s not that complicated. The binary search is simple, clear and universal.

Image description

We know that our point lies within a certain range. We take the average value and check its coordinates. If the X coordinate is too far from the required coordinate, then we should narrow the range by half and repeat until the required point is as close as possible.

The red dots and blue lines define the limits of our search range. The red dot in the middle is the coordinate we’re checking. The green dot indicates the point we’re looking for.

The first iteration shows that the point in question is pretty far from the center of the range. So we can reduce the range by half because we know that our point is on the right. In the second iteration, we find the value of the point. This algorithm is kind of rough, but it works.

private fun findYOnChart(x: Float): Float {
    val coordinates = floatArrayOf(0f, 0f)
    var rangeStart = 0f
    var rangeEnd = pathMeasure.length
    var midDistance: Float

    while (rangeStart.farFrom(rangeEnd, POINT_LOOKUP_TOLERANCE)) {
        midDistance = rangeStart + (rangeEnd - rangeStart) / 2
        pathMeasure.getPosTan(midDistance, coordinates, null)
        when {
            coordinates[0].closeTo(x, POINT_LOOKUP_TOLERANCE) -> return coordinates[1]
            coordinates[0] < x -> rangeStart = midDistance
            else -> rangeEnd = midDistance
        }
    }

    return coordinates[1]
}
Enter fullscreen mode Exit fullscreen mode

Your code will look something like this. In the loop, we check all points until we find the necessary one, or until the range becomes minimal.


Why won’t CornerPathEffect work for us?

The answer is pretty simple. PathEffect is used at the rendering stage (this means that the input values are not transformed). PathMeasure uses data from Path.

Image description

On the screen, we see the smooth line of the chart, but it won’t be like this for PathMeasure. So we won’t be able to find a point on the graph.

Now we know how to position the graph, only the text is left.

The text (getTextBounds)

Image description

As you can see in the image, the text and the marker on its left are horizontally centered. Simply using canvas.drawText won’t give us this effect. For example, if we specify the Y coordinate which equals to the marker coordinate, we’ll get the following:

Image description

To get a better result, we need to know the text height. getTextBounds can help us out.

val textBounds = Rect()
...
textPaint.getTextBounds(valueText, 0, valueText.length, textBounds)
canvas.drawText(valueText, currentValueLabelOffset + smallDotRadius * 2f,
                view.height - currentValueLabelOffset + textBounds.height() * 0.5f - textBounds.bottom, textPaint)
view raw
Enter fullscreen mode Exit fullscreen mode

This method returns the Rect-type object containing info on the text height. It’s also worth noting that different fonts may differ in other parameters too, not only height.

Image description

The bottom indent, for example — you have to take it into account when you centre the text.


Looks like we’ve done all the preparation work! I guess you want to start the app already — we’re almost there. One last thing — debugging.

Debugging & Profiling

To debug your View, it’s enough to turn on the profiling of the GPU renderer. It’ll be able to show all the performance issues. If you have concerns about the memory usage, then you should get the built-in profiler in the Android Studio.

Image descriptionTurn on profiling in the developers’ settings
Image description

A profiler looks like this image; a set of columns showing the processing time of each frame. The green line indicates the threshold value — if reached and exceeded, the frame is skipped. On the screens that refresh at the standard rate of 60 Hz, it takes about 16 ms to process a frame.

Note also that each column consists of coloured segments. They visualise which part of the renderer consumes most of the resources.

Image description

In Android documentation, you’ll find a description of each step. When we draw using onDraw, we’re only interested in the final few steps:

  • Draw. The onDraw method (and not just yours) affects this step. Long execution will cause problems.
  • Sync & Upload. Namely, loading the bitmaps and lists for GPU rendering. Issues arise because of a large amount of data and hardware limitations (for example, slow memory).
  • Command Issue. It’s responsible for calling OpenGL (or Vulkan) commands on the GPU. Calling more commands results in longer execution.
  • Swap Buffers. The final step where the CPU is waiting for the GPU to shut down. You can often see this problem if you are profiling on an emulator.

And another useful setting, which may help you optimize rendering, is the Overdraw Visualization. I won’t elaborate on this now, but just note that this setting helps you see which Views are drawn more than once per frame.


General recommendations on profiling

  1. Prioritise using a real device over the emulator.
  2. Check using different APIs.

I’ve singled out the 4 versions of Android that you might need to consider:

  • KitKat: I hope you don’t have to support this version. But if you do, just remember that rendering is performed in the UI thread here.
  • Lollipop: RenderThread was added to the system starting from this version.
  • Marshmallow: I’m not aware of its peculiarities, but various issues are associated with this version, so do your research and stay alert.
  • Oreo: The rendering pipeline was upgraded in this version eliminating the need to call invalidateRect.

Newer versions work just fine for us (yay!).

Now we’re ready to start our app. Let’s go.

First launch

Image description

Everything works as expected. However, the Profiler shows that the rendering of some frames takes more than 16 ms. Frames are skipped as a result, and we may notice some stuttering.

Later the chart becomes even slower.

Image description

In the end, it just disappears.

Image description

What happened? To find out, you’ll have to come back for the next and final part in this series. Stay tuned to learn about Performance and Optimisation!

Top comments (0)