Classes in depth Chapter 26 of 35

The classes that paint a chart onto its canvas, how a chart wires them up, and how to replace one with your own.

A renderer is a plain object that takes an android.graphics.Canvas and draws on it. It holds no data. It reads the chart through an interface, asks a Transformer for pixel positions, checks the ViewPortHandler for what is on screen, and paints. A custom data set changes the numbers a renderer is handed. A custom renderer changes what ends up on the screen.

The hierarchy#

Everything that draws extends Renderer, an abstract class whose whole body is one protected viewPortHandler. Three branches grow out of it.

ClassConstructorDraws
DataRenderer(animator, viewPortHandler)the data: bars, lines, slices, values
AxisRenderer(viewPortHandler, transformer, axis)labels, grid lines, axis line, limit lines
LegendRenderer(viewPortHandler, legend)the legend entries and their forms

DataRenderer is the one you will subclass most. It declares five members a subclass must fill in:

FunctionWhen it runsWhat it does
initBuffers()on every data change, not per framecreates or resizes the buffers for the new data
drawData(c)first in a draw passthe shapes themselves
drawHighlighted(c, indices)right after drawDatathe indicators for the selected entries
drawExtras(c)after the clip is liftedline circles, the radar web, the pie hole
drawValues(c)last, after the axis labelsthe value labels and entry icons

It also owns four paints. Three of them have a read only property in front, and because a Paint is mutable that is enough to restyle a built-in renderer without subclassing it:

Protected fieldPublic propertyDefault
renderPaintpaintRenderanti-aliased, Paint.Style.FILL
highlightPaintpaintHighlight2 px stroke, orange rgb(255, 187, 115)
valuePaintpaintValuesrgb(63, 63, 63), centered, 9 dp
drawPaintnonedither flag only, unused by the built-in renderers

Two helpers are worth knowing. isDrawingValuesAllowed(chart) is the rule behind maxVisibleCount: it returns true only while the entry count is below chart.maxVisibleCount * viewPortHandler.scaleX. applyValueTextStyle(set) copies the typeface and text size of a data set into valuePaint, once per data set before its labels are drawn. drawValue, whose signature is in the worked example below, is the single open function on DataRenderer and the cheapest override in the library.

Renderers with axes#

BarLineScatterCandleBubbleRenderer sits between DataRenderer and the renderers of the charts that have an x and a y axis. It adds shouldDrawValues(set), isInBoundsX(entry, set) and the xBounds field, an instance of its inner XBounds class:

MemberMeaning
minindex of the first visible entry
maxindex of the last visible entry
range(max - min) scaled by the x animation phase
set(chart, dataSet)recomputes all three from lowestVisibleX and highestVisibleX

set rounds the first entry down and the last one up, so the entries just off screen are included and lines do not end in mid air at the edge. Every renderer under this class calls xBounds.set(chart, dataSet) once per data set and then loops from min to max only.

Two more layers sit on top: LineScatterCandleRadarRenderer adds drawHighlightLines(c, x, y, set), the vertical and horizontal crosshair, and LineRadarRenderer adds two drawFilledPath overloads, one for a color with an alpha and one for a Drawable.

Which renderer each chart creates#

Every chart builds its renderers in its init function and stores them in lateinit var properties. The data renderer is always chart.renderer:

Chartrenderer
LineChartLineChartRenderer
BarChartBarChartRenderer
HorizontalBarChartHorizontalBarChartRenderer
ScatterChartScatterChartRenderer
CandleStickChartCandleStickChartRenderer
BubbleChartBubbleChartRenderer
CombinedChartCombinedChartRenderer
PieChartPieChartRenderer
RadarChartRadarChartRenderer

Every chart that extends BarLineChartBase also has rendererXAxis, rendererLeftYAxis and rendererRightYAxis, an XAxisRenderer and two YAxisRenderer instances. HorizontalBarChart swaps all three for XAxisRendererHorizontalBarChart and YAxisRendererHorizontalBarChart. PieChart has no axis renderers at all, and RadarChart names its two differently: xAxisRenderer of type XAxisRendererRadarChart and yAxisRenderer of type YAxisRendererRadarChart. legendRenderer is on every chart too, but its setter is protected, so you restyle it rather than replace it: chart.legendLabelPaint reads and writes the renderer's labelPaint.

Replacing one#

Assign your instance to the property. Every data renderer takes the chart, the animator and the viewport handler, in that order:

BarChart(
    data = barData,
    setup = {
        renderer = BadgeBarRenderer(this, animator, viewPortHandler)
    },
)

From a view, do the same before the data:

val chart = BarChart(context)
chart.renderer = BadgeBarRenderer(chart, chart.animator, chart.viewPortHandler)
chart.data = barData

Order matters. Setting chart.data calls notifyDataSetChanged(), and that is what calls renderer.initBuffers(). Assign the renderer first and the buffers are built for you. Assign it afterwards and you have to call chart.notifyDataSetChanged() yourself, or the first draw will read buffers that were never filled. The Compose setup lambda runs once after the view is created and before any data is set, which is exactly the right moment.

An axis renderer needs the axis it draws and a transformer instead of the animator:

chart.rendererXAxis = TickedXAxisRenderer(
    chart.viewPortHandler,
    chart.xAxis,
    chart.getTransformer(YAxis.AxisDependency.LEFT),
)

XAxisRendererHorizontalBarChart takes a fourth argument, the BarChart itself. The two radar axis renderers take (viewPortHandler, axis, chart) and no transformer, because a radar chart places its labels from the center and the slice angle rather than through a matrix. Note that chart.renderer is typed DataRenderer, so reading it back for anything type specific needs a cast.

A worked example#

Draw every bar value on a soft rounded badge. The only override is drawValue, so all the positioning, the stacked case, the icons and the bounds checks stay as they are:

class BadgeBarRenderer(
    chart: BarDataProvider,
    animator: ChartAnimator,
    viewPortHandler: ViewPortHandler,
) : BarChartRenderer(chart, animator, viewPortHandler) {

    private val badgePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = Color.argb(30, 0, 0, 0)
    }
    private val badge = RectF()

    override fun drawValue(
        c: Canvas,
        formatter: IValueFormatter,
        value: Float,
        entry: Entry<*>,
        dataSetIndex: Int,
        x: Float,
        y: Float,
        color: Int,
    ) {
        val text = formatter.getFormattedValue(
            value, entry, dataSetIndex, viewPortHandler
        )
        val padding = Utils.convertDpToPixel(3f)
        val half = paintValues.measureText(text) / 2f + padding
        badge.set(x - half, y - paintValues.textSize, x + half, y + padding)
        c.drawRoundRect(badge, padding, padding, badgePaint)
        super.drawValue(c, formatter, value, entry, dataSetIndex, x, y, color)
    }
}

The constructor takes BarDataProvider, not BarChart, because that is what BarChartRenderer declares, and y is the text baseline rather than the top of the label.

To change the bars themselves rather than their labels, override drawDataSet(c, dataSet: IBarDataSet<*>, index: Int) on the same class, or drawDataSet(c, dataSet: ILineDataSet<*>) on LineChartRenderer, or drawDataSet(c, dataSet: IPieDataSet<*>) on PieChartRenderer. LineChartRenderer also opens up drawLinear, drawCubicBezier, drawHorizontalBezier, drawCubicFill, drawLinearFill, drawCircles and drawHighlightCircle one by one, so you rarely have to replace a whole line path.

The draw order#

Chart.onDraw itself draws almost nothing: it paints noDataText when there is no data and calculates the offsets once. Everything else lives in the subclass. BarLineChartBase.onDraw runs this sequence on every frame:

  1. the grid background, then autoScale() when isAutoScaleMinMaxEnabled
  2. computeAxis on the left y, right y and x axis renderers, for the enabled axes
  3. renderAxisLine on all three
  4. renderGridLines and then renderLimitLines for every axis with isDrawGridLinesBehindDataEnabled or isDrawLimitLinesBehindDataEnabled set
  5. the canvas is saved and clipped to viewPortHandler.contentRect when isClipDataToContentEnabled
  6. renderer.drawData
  7. renderGridLines for the axes that draw their grid in front of the data
  8. renderer.drawHighlighted, only when something is highlighted
  9. the clip is restored, then renderer.drawExtras
  10. renderLimitLines for the axes that draw in front
  11. renderAxisLabels on all three axis renderers
  12. renderer.drawValues, clipped again when isClipValuesToContentEnabled
  13. legendRenderer.renderLegend, the description, the markers

So drawData is clipped to the content rectangle and drawExtras is not. A line chart relies on that: it draws its circles in drawExtras so a circle at the very edge is not sliced in half.

PieChart.onDraw is far shorter: drawData, drawHighlighted, drawExtras, drawValues, legend, description, markers. RadarChart.onDraw calls drawExtras first of all when isDrawWebEnabled, so the web ends up behind the polygons.

What every renderer reads#

Two objects are handed to every renderer and are the reason it can stay stateless. The animator supplies phaseX and phaseY, both 1 when nothing is animating. phaseX is a fraction of the entry count: a renderer stops its loop at entryCount * phaseX, which is why an x animation reveals entries one by one. phaseY multiplies the y value of each entry before it is transformed, which is why a y animation grows the shapes out of the zero line. Respect both or your renderer will ignore the animations.

The viewport handler is the content rectangle and the current zoom, in pixels. Renderers use contentLeft, contentTop, contentRight, contentBottom to draw and isInBoundsLeft(x), isInBoundsRight(x), isInBoundsY(y) and isInBounds(x, y) to skip what is off screen. The full API is in the ViewPortHandler. The pattern below is worth copying, because the two checks are not symmetric:

if (!viewPortHandler.isInBoundsRight(pixel)) break
if (!viewPortHandler.isInBoundsLeft(pixel)) continue

Entries arrive in ascending x, so once one is past the right edge every later one is too and the loop can stop. One before the left edge only means this one is skipped.

The data provider interfaces#

A renderer never names a chart class. It is written against a small interface in interfaces.dataprovider, which is what lets CombinedChart reuse all five data renderers unchanged.

InterfaceAddsRead by
ChartInterfacedata, contentRect, maxVisibleCount, the value and pixel rangesevery renderer and marker
BarLineScatterCandleBubbleDataProvidergetTransformer(axis), isInverted(axis), lowestVisibleX, highestVisibleXthe renderers with axes
BarDataProviderbarData, isDrawBarShadowEnabled, isDrawValueAboveBarEnabled, isHighlightFullBarEnabledBarChartRenderer
LineDataProviderlineData, getAxis(axis)LineChartRenderer
ScatterDataProviderscatterDataScatterChartRenderer
CandleDataProvidercandleDataCandleStickChartRenderer
BubbleDataProviderbubbleDataBubbleChartRenderer
CombinedDataProvidercombinedData, extends the five aboveCombinedHighlighter

PieChartRenderer and RadarChartRenderer are the exceptions. They take PieChart and RadarChart directly, because they need the center, the radius and the rotation angle, none of which are on an interface. Take the narrowest interface that gives you what you need and your renderer works on the combined chart as well as on the single type chart.

The combined chart renderer#

CombinedChartRenderer draws nothing itself. It owns subRenderers, a mutable list of DataRenderer, and forwards all four draw functions plus initBuffers to each entry in order. createRenderers() rebuilds that list. It walks chart.drawOrder and adds a renderer only for a data kind that is actually present, so a combined chart holding line and bar data gets exactly two sub renderers, in the order the draw order lists them. The list is rebuilt for you whenever you set chart.data, and you only call createRenderers() by hand after changing drawOrder without setting data again. drawHighlighted is the one function that does not simply forward. It matches each highlight against the data index of the sub renderer's own data and passes on only the highlights that belong to it, plus any with a data index of -1, which are meant for every renderer.

getSubRenderer(index) returns an entry or null when the index is out of range. Replacing one entry in subRenderers is how you change a single data kind inside a combined chart, but do it after the data is set, because the data setter rebuilds the whole list.

Buffers#

Drawing runs on every frame, so the bar renderers keep their coordinates in a reusable FloatArray rather than allocating one. AbstractBuffer<T> is that array plus a write position: buffer holds the floats, feed(data) fills them, setPhases(phaseX, phaseY) sets the animation phases used by the next feed, and reset() moves the write position back to the start.

BarBuffer is the bar layout: four floats per bar, left, top, right and bottom, in value space. Left and right are x - barWidth / 2 and x + barWidth / 2; top and bottom span from the bar value to zero, swapped when the y axis is inverted. A stacked entry produces one rectangle per stack value. HorizontalBarBuffer keeps the same four floats but swaps their meaning: left and right are y values, top and bottom are x values.

BarChartRenderer.initBuffers creates one buffer per data set, sized entryCount * 4, times the stack size for a stacked set. drawDataSet then feeds the buffer, converts the whole array to pixels in one trans.pointValuesToPixel(buffer.buffer) call and steps through it four floats at a time. One matrix call for a whole data set is the reason the buffers exist. Only the bar renderers use them. ScatterChartRenderer, CandleStickChartRenderer, RadarChartRenderer and PieChartRenderer all have an empty initBuffers, and LineChartRenderer uses an offscreen bitmap instead, and only for the modes that need one.

Scatter shape renderers#

The smallest custom drawing hook in the library is not a renderer subclass at all. IShapeRenderer is a fun interface with a single renderShape(c, dataSet, viewPortHandler, posX, posY, renderPaint), which ScatterChartRenderer calls once per entry with the pixel position already worked out and renderPaint already set to the color of that entry. A lambda is enough:

val set = ScatterDataSet(entries, "Points")
set.shapeRenderer = IShapeRenderer { c, dataSet, _, x, y, paint ->
    val half = dataSet.scatterShapeSize / 2f
    c.drawLine(x - half, y - half, x + half, y + half, paint)
}

The built-in implementations are SquareShapeRenderer (the default on ScatterDataSet), CircleShapeRenderer, TriangleShapeRenderer, CrossShapeRenderer, XShapeRenderer, ChevronUpShapeRenderer and ChevronDownShapeRenderer, and set.setScatterShape(ScatterChart.ScatterShape.CIRCLE) is a shortcut that installs one of them. Every one of them uses scatterShapeSize as pixels but scatterShapeHoleRadius as dp, so keep to that if you want your shape to match the others.

What a renderer must not do#

A draw function runs on the UI thread up to sixty times a second, once per data set, and often once per visible entry. Everything written here follows from that.

Do not allocate in a draw function. No Paint, no Path, no RectF, no FloatArray, no boxing. Create them as fields, as the built-in renderers do with barRect, barShapePath and the various float buffers. For points, use MPPointF.getInstance(...) and hand the instance back with MPPointF.recycleInstance(...) when you are done.

Do not hold a strong reference that outlives the chart. A renderer is reachable from the view, so holding the view back is not itself a leak, but anything longer lived that holds your renderer then holds the whole view hierarchy. CombinedChartRenderer and LineChartRenderer both use a java.lang.ref.WeakReference for exactly this reason, and both handle the reference having been cleared.

Do not skip the bounds checks. Without xBounds and the isInBounds calls you transform and draw every entry in the data set, including the thousands that are off screen. That is the difference between a chart that scrolls smoothly and one that does not.

And keep dp conversions inside Utils.convertDpToPixel rather than hard coding pixel values, so your renderer looks the same on every screen density.