Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How do MPAndroidChart renderers work and how do I write a custom renderer?

I am using the library MPAndroidChart but it doesn't have all of the functionality I want out of the box.

I have heard that it is possible to implement the functionality I want by writing a custom renderer.

I have looked at the source code for the renderers in the MPAndroidChart GitHub repo, but I can't understand the concepts involved.

How do MPAndroidChart renderers work?

What is the high-level procedure for writing a custom renderer?

like image 865
David Rawson Avatar asked Apr 17 '17 01:04

David Rawson


1 Answers

Understanding Views and Canvas

First, one should study the Canvas and Drawables Guide from the official Android documentation. Particularly, it is important to note that LineChart, BarChart etc. are subclasses of View that display themselves by overriding the onDraw(Canvas c) callback of the View superclass. Note also the definition of "canvas":

A Canvas works for you as a pretense, or interface, to the actual surface upon which your graphics will be drawn — it holds all of your "draw" calls.

When you are working with renderers, you will be dealing with the functionality for drawing lines, bars, etc. on the canvas.

Translation between values on the chart and pixels on the canvas

Points on the chart are specified as x and y values with respect to the units on the chart. For example, in the chart below, the centre of the first bar is at x = 0. The first bar has the y-value of 52.28.

an MPAndroidChart barchart

This clearly does not correspond to the pixel co-ordinates on the canvas. On the canvas, x = 0 on the canvas would be a left-most pixel which are clearly blank. Likewise, because pixels enumeration starts from the top as y = 0, the tip of the bar is clearly not at 52.28 (the y-value on the chart). If we use Developer options/Pointer location we can see that the tip of the first bar is approximately x = 165 and y = 1150.

A Transformer is responsible for converting chart values to pixel (on-screen) co-ordinates and vice-versa. A common pattern in renderers is to perform calculations using chart values (which are easier to understand) and then at the end use the transformer to apply a transformation for rendering onto the screen.

View port and bounds

A view port is a window i.e., a bounded area on the chart. View ports are used to determine which part of the chart the user can currently see. Each chart has a ViewPortHandler that encapsulates the functionality related to view ports. We can use ViewPortHandler#isInBoundsLeft(float x) isInBoundsRight(float x) to determine which x values the user can currently see.

In the chart pictured above, the BarChart "knows about" the BarEntry for 6 and above but because they are out of bounds and not in the current viewport, 6 and upward are not rendered. Hence, x-values 0 through to 5 are within the current viewport.

ChartAnimator

The ChartAnimator provides an additional transformation to be applied to the chart. Usually this is a simple multiplication. For example, assume we want an animation where the points of the chart start at the bottom and gradually rise to their correct y-value over 1 second. The animator will provide a phaseY that is a simple scalar starts at 0.000 at time 0ms and rises gradually to 1.000 at 1000ms.

An example of renderer code

Now that we understand the basic concepts involved, let's look at some code from LineChartRenderer:

protected void drawHorizontalBezier(ILineDataSet dataSet) {

    float phaseY = mAnimator.getPhaseY(); 

    Transformer trans = mChart.getTransformer(dataSet.getAxisDependency());

    mXBounds.set(mChart, dataSet);

    cubicPath.reset();

    if (mXBounds.range >= 1) {

        Entry prev = dataSet.getEntryForIndex(mXBounds.min);
        Entry cur = prev;

        // let the spline start
        cubicPath.moveTo(cur.getX(), cur.getY() * phaseY);

        for (int j = mXBounds.min + 1; j <= mXBounds.range + mXBounds.min; j++) {

            prev = cur;
            cur = dataSet.getEntryForIndex(j);

            final float cpx = (prev.getX())
                    + (cur.getX() - prev.getX()) / 2.0f;

            cubicPath.cubicTo(
                    cpx, prev.getY() * phaseY,
                    cpx, cur.getY() * phaseY,
                    cur.getX(), cur.getY() * phaseY);
        }
    }

    // if filled is enabled, close the path
    if (dataSet.isDrawFilledEnabled()) {

        cubicFillPath.reset();
        cubicFillPath.addPath(cubicPath);
        // create a new path, this is bad for performance
        drawCubicFill(mBitmapCanvas, dataSet, cubicFillPath, trans, mXBounds);
    }

    mRenderPaint.setColor(dataSet.getColor());

    mRenderPaint.setStyle(Paint.Style.STROKE);

    trans.pathValueToPixel(cubicPath);

    mBitmapCanvas.drawPath(cubicPath, mRenderPaint);

    mRenderPaint.setPathEffect(null);
}

The first few lines before the for loop are the setup for the renderer loop. Note that we obtain the phaseY from the ChartAnimator, the Transformer, and calculate the view port bounds.

The for loop basically means "for each point that is within the left and right bounds of the view port". There is no point in rendering x-values that cannot be seen.

Within the loop, we get the x-value and y-value for the current entry using dataSet.getEntryForIndex(j) and create a path between that and the previous entry. Note how the path are all multiplied by the phaseY for animation.

Finally, after the paths have been calculated a transformation is applied with trans.pathValueToPixel(cubicPath); and the paths are rendered to the canvas with mBitmapCanvas.drawPath(cubicPath, mRenderPaint);

Writing a custom renderer

The first step is choosing the correct class to subclass. Note the classes in the package com.github.mikephil.charting.renderer including XAxisRenderer and LineChartRenderer etc. Once you create a subclass, you can simply override the appropriate method. As per the example code above, we would override void drawHorizontalBezier(ILineDataSet dataSet) without calling super (so as to not invoke the rendering stage twice) and replace it with the functionality we want. If you're doing it right, the overridden method should look at least a little bit like the method you are overriding:

  1. Obtaining a handle on the transformer, animator, and bounds
  2. Looping through the visible x-values (the x-values that are within the view port bounds)
  3. Preparing points to render in chart values
  4. Transforming the points into pixels on the canvas
  5. Using the Canvas class methods to draw on the canvas

You should study the methods in the Canvas class (drawBitmap etc.) to see what operations you are allowed to perform in the renderer loop.

If the method that you need to override is not exposed, you may have to subclass a base renderer like LineRadarRenderer to achieve the desired functionality.

Once you have engineered the renderer subclass you want, you can consume it easily with the Chart#setRenderer(DataRenderer renderer) or BarLineChartBase#setXAxisRenderer(XAxisRenderer renderer) and other methods.

like image 164
David Rawson Avatar answered Oct 31 '22 16:10

David Rawson