Android draw Line Chart with Canvas

Gustavo Santorio
Android Playground
Published in
5 min readApr 12, 2021

For Android Development the Canvas Framework is the one of most important technology that you can learn, however is one of the most underestimated too. In this article I will explain how to create a simple Line Chart with Canvas, and how you can simplify the drawing process.

When you create a View and override the onDraw(…) you earn all power of that View, and all responsibilities too. An important responsibility is not put much process in this method, because it is called in every state of a view, every invalidade(…), requestLayout(…), onSizeChanged(..), etc… If it have a lot of process like calculations, loops or object instantiation, then your application will have Main Thread problems, and lost of performance.

I will show below a strategy for separate your view in the data responsibility, and drawing responsibility. In this way it's easy to pass and update the content of your chart.

For the Data content we need to create an Adapter that you pass and update the Chart data, and notify when the data has changed.

I create an abstract class with some common implementations that call's ChartAdapter:

open val dataBounds: RectF
get() {
val count = count
val
hasBaseLine = hasBaseLine()
var minY = Float.MAX_VALUE
var
maxY = -Float.MAX_VALUE
var
minX = Float.MAX_VALUE
var
maxX = -Float.MAX_VALUE
for
(i in 0 until count) {
val x = getX(i)
minX = minX.coerceAtMost(x)
maxX = maxX.coerceAtLeast(x)
val y = getY(i)
minY = minY.coerceAtMost(y)
maxY = maxY.coerceAtLeast(y)
}

return createRectF(minX, minY, maxX, maxY)
}

protected fun createRectF(
left: Float,
top: Float,
right: Float,
bottom: Float
): RectF = RectF(left, top, right, bottom)
open fun hasBaseLine(): Boolean =
false

This class has some functions to be implemented by your child:

abstract val count : Int

abstract fun getItem(index: Int): Any

abstract fun getY(index: Int): Float

abstract fun getX(index: Int): Float

To notify the content change I used the DataSetObservable, but you can use any observer you want like LiveData an RX frameworks.

private val observable = DataSetObservable()fun notifyDataSetChanged() {
observable.notifyChanged()
}

fun notifyDataSetInvalidated() {
observable.notifyInvalidated()
}

fun registerDataSetObserver(observer: DataSetObserver) {
observable.registerObserver(observer)
}

fun unregisterDataSetObserver(observer: DataSetObserver) {
observable.unregisterObserver(observer)
}

For the LineChart the adapter implementation is:

open class LineChartAdapter(protected var yData: FloatArray = floatArrayOf()) : ChartAdapter() {

override val count: Int
get() = yData.size

open fun
setData(yData: FloatArray) {
this.yData = yData
notifyDataSetChanged()
}

fun setDataWithoutNotify(yData: FloatArray) {
this.yData = yData
}

override fun hasBaseLine(): Boolean =
containsNegativeValue()

private fun containsNegativeValue(): Boolean {
for (value in yData) {
if (value < 0)
return true
}
return false
}

override fun getItem(index: Int): Any =
yData[index]

override fun getY(index: Int): Float =
yData[index]

fun clearData(){
setData(floatArrayOf())
}
}

Note that we receive a FloatArray as parameter that represents your line chart(Y columns). The abstract methods getItem(…), getY(…) was implemented returning the Y data. The other methods is just to set new data and notify the super.

Now we have the data layer created, and need to consume it in the view. I will not explain in this post how to get and set attrs in a View, but will create an article talking about that.

To convert data in pixel I use an class called ScaleHelper, that the implementation will help a lot.

class ScaleHelper(adapter: ChartAdapter, contentRect: RectF, lineWidth: Float, fill: Boolean) {
val width: Float
val height: Float
val size: Int
private val xScale: Float
private val yScale: Float
private val xTranslation: Float
private val yTranslation: Float

init {
val leftPadding = contentRect.left
val
topPadding = contentRect.top

var
lineWidthOffset = 0.0f

if (!fill)
lineWidthOffset = lineWidth

this.width = contentRect.width() - lineWidthOffset
this.height = contentRect.height() - lineWidthOffset

this.size = adapter.count

val
bounds = adapter.dataBounds

bounds.inset((if (bounds.width() == 0f) -1 else 0).toFloat(), (if (bounds.height() == 0f) -1 else 0).toFloat())

val minX = bounds.left
val
maxX = bounds.right
val
minY = bounds.top
val
maxY = bounds.bottom

this
.xScale = width / (maxX - minX)
this.xTranslation = leftPadding - minX * xScale + lineWidthOffset / 2
this.yScale = height / (maxY - minY)
this.yTranslation = minY * yScale + topPadding + lineWidthOffset / 2
}

fun getX(rawX: Float): Float =
rawX * xScale + xTranslation


fun
getY(rawY: Float): Float =
height - rawY * yScale + yTranslation
}

This class will return the respective pixel value of the Chart Data based on the view content rect. Note that contentRect represents the view size, and with this information the Helper calculate the View width and heigth.

Now we have the View's Adapter, then we need to create the View. The first thing we need to do is calculate where canvas will draw our data and the space in view that we can use. We need to override two View Methods to know it.

open class LineChartView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.LineChartStyle,
defStyleRes: Int = R.style.LineChartView
) : View(context, attrs, defStyleAttr) {
protected val contentRect = RectF()override fun onSizeChanged(w: Int, h: Int, oldW: Int, oldH: Int) {
super.onSizeChanged(w, h, oldW, oldH)
updateContentRect()
populatePath() /*I will show later*/
}

override fun setPadding(left: Int, top: Int, right: Int, bottom: Int) {
super.setPadding(left, top, right, bottom)
updateContentRect()
populatePath() /*I will show later*/
}
private fun updateContentRect() {
contentRect.set(
paddingStart.toFloat(),
paddingTop.toFloat(),
(width - paddingEnd).toFloat(),
(height - paddingBottom).toFloat()
)
}
}

In this two methods we receive a size changed delegation, then we set the View limits calculated with View paddings and dimensions. Every time when this methods are called we need to calculate and redraw our canvas.

The next step is use the Rect to create a Canvas Path with all points to draw. Using our ScaleHelper we can get the x and y point in View to our Adapter Data.

private fun populatePath() {

scaleHelper = ScaleHelper(adapter, contentRect, lineWidth, isFillInternal)
renderPath.reset()

for (i in 0 until adapter.count) {
val x = scaleHelper.getX(adapter!!.getX(i))
val y = scaleHelper.getY(adapter!!.getY(i))

if (i == 0)
renderPath.moveTo(x, y)
else
renderPath
.lineTo(x, y)
}
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawPath(renderPath, renderPaint)
}

We are using two Path methods moveTo(…) to put our path in the first position, and lineTo(…) to create a line between the first point to second. Then we call invalidate() method to call View redrawing. In onDraw(…) method we call Canvas drawPath(…) method to put in View our path.

We need to note some that all callculations are done before onDraw(…). It's important to know that this method is called after all View change (Size, padding, lifecycle), then put heavy processes here will result in main thread performance problems.

This is the visual result of our chart. We can use Paint to draw any color and style. In next articles I will show how to use attr's in a Custom View, and how to take the next level with Canvas

Any doubt let me know in comments please. Thanks!!

--

--