4.坐标轴的绘制(MPAndroidChart源码解析)

1,744 阅读6分钟

1.绘制坐标轴的思路:

1.计算x,y的起始值和结束值
2.根据显示的值范围将坐标轴显示的数据进行分组,获取间隔值,并确定每个间隔要显示的value
3.计算坐标轴label的需要的绘制空间和位置,位置就是将上面的value转换为px值

2.涉及的主要类

包含配置类和render类,如下图所示:

相关类.png

2.1 配置类:

//只看主要的成员变量和方法
public abstract class AxisBase extends ComponentBase {

    /**
     * label显示值的格式化类
     */
    protected IAxisValueFormatter mAxisValueFormatter;
    /**
     * 是否自定义mAxisMin,比如y轴的最小值大于0,我们可以将y轴的最小值设置为0
     */
    protected boolean mCustomAxisMin = false;
    /**
     * 所要绘制的元素集合
     */
    public float[] mEntries = new float[]{};
    /**
     * 坐标轴绘制的元素数量
     */
    public int mEntryCount;
    /**
     * 这条轴上绘制label的数量,比如y轴显示了20条数据,如果每一条都显示label的话可能会造成重叠,
     * 所以这个参数可以设置可显示的最大的label绘制个数
     */
    private int mLabelCount = 6;

    /**
     * 绘制的线是以每个item的中心点为起点的
     */
    protected boolean mDrawGridLines = true;

    /**
     * 是否绘制坐标轴线
     */
    protected boolean mDrawAxisLine = true;
    /**
     * 是否要绘制label
     */
    protected boolean mDrawLabels = true;

 /**
     * 获取所有label中最长的,使用场景在于,当label设置了旋转角度,绘制chart的区域就要根据角度和这个长度来计算,感觉这种设定并不好,会造成chart的区域大小不一,不如添加设置label显示的最大最小长度,否则名称过长了会挤压content的绘制区域
     *注意:这个方法只是返回了最大字符数的label,中英文的话就不准确了
     */
    public String getLongestLabel() {
        String longest = "";
        for (int i = 0; i < mEntries.length; i++) {
            String text = getFormattedLabel(i);
            if (text != null && longest.length() < text.length())
                longest = text;
        }
        return longest;
    }

    public String getFormattedLabel(int index) {

        if (index < 0 || index >= mEntries.length)
            return "";
        else
            return getValueFormatter().getFormattedValue(mEntries[index], this);
    }
}

2.2 绘制类

public abstract class AxisRenderer extends Renderer {

    protected AxisBase mAxis;
    protected Transformer mTrans;
    ...
    public AxisRenderer(ViewPortHandler viewPortHandler, AxisBase xAxis, Transformer transformer) {
        super(viewPortHandler);
        this.mAxis = xAxis;
        this.mTrans = transformer;
    }
    
    //绘制文本
    public abstract void renderAxisLabels(Canvas c);
    //绘制分组线
    public abstract void renderGridLines(Canvas c);
    //绘制坐标轴线
    public abstract void renderAxisLine(Canvas c);
    //绘制限制线
    public abstract void renderLimitLines(Canvas c);
   
    /**
     * 计算坐标轴的值
     * @param min
     * @param max
     * @param invert
     */
    public void computeAxis(float min, float max, boolean invert) {
        //这里只判断y的缩放是因为XAxisRenderer会对这个方法进行覆写
        //如果y轴的缩放比例不是最小,就重新计算最大最小值
        if (mViewPortHandler != null && mViewPortHandler.contentWidth() > 10 && !mViewPortHandler.isFullyZoomedOutY()) {
            //y轴上获得当前显示top点和bottom点,然后获取这两个坐标点(px值)所代表的实际的value值
            MPPointD p1 = mTrans.getValuesByTouchPoint(mViewPortHandler.contentLeft(), mViewPortHandler.contentTop());
            MPPointD p2 = mTrans.getValuesByTouchPoint(mViewPortHandler.contentLeft(), mViewPortHandler.contentBottom());

            if(!invert){
                min = (float)p2.y;
                max = (float)p1.y;
            }else{
                min = (float)p1.y;
                max = (float)p2.y;
            }

            MPPointD.recycleInstance(p1);
            MPPointD.recycleInstance(p2);
        }
        //计算索要绘制的标签
        computeAxisValues(min, max);
    }

/**
 * 计算最大值和最小值之间所需的标签数量
 * */
    protected void computeAxisValues(float min,float max){

         float yMin = min;
         float yMax = max;

         int labelCount = mAxis.getLabelCount();
        //1.计算所要绘制的数据区间
         double range = Math.abs(yMax - yMin);
        ...
        //2.根据设置的显示label的个数和数值区间初步获得 间隔值
         double rawInterval = range/labelCount;
         //向下取整,间隔当然得是整数
         double interval = Utils.roundToNextSignificant(rawInterval);
        if (mAxis.isGranularityEnabled())
            interval = interval < mAxis.getGranularity() ? mAxis.getGranularity() : interval;
        //一个间隔值中内部的间隔值,比如groupbar这种情况
        double intervalMagnitude = Utils.roundToNextSignificant(Math.pow(10,(int)Math.log10(interval)));
    ...
        //3.根据间隔值,起始值和终点值计算出具体需要好绘制的value,比如x轴的mEntries 包含的是 2,3,4 表示要绘制的是2,3,4的值
        int n = mAxis.isCenterAxisLabelsEnabled() ? 1 : 0;
    ...

        double first = interval == 0.0 ? 0.0 : Math.ceil(yMin / interval) * interval;
        if(mAxis.isCenterAxisLabelsEnabled()) {
            first -= interval;
        }

        double last = interval == 0.0 ? 0.0 : Utils.nextUp(Math.floor(yMax / interval) * interval);

        double f;
        int i;

        if (interval != 0.0) {
            for (f = first; f <= last; f += interval) {
                ++n;
            }
        }

        mAxis.mEntryCount = n;
        if (mAxis.mEntries.length < n) {
            mAxis.mEntries = new float[n];
        }

        for(f = first, i = 0; i < n; f += interval, ++i) {
            if (f == 0.0) 
                f = 0.0;
            mAxis.mEntries[i] = (float) f;
        }
        ...
    }
}

从上面的代码我们获得的mEntries只是原始数据的value,绘制的时候需要将其转换成px值,转换的过程就是根据前面介绍的Transformer,以y轴绘制labels为例:

@Override
    public void renderAxisLabels(Canvas c) {

        if (!mYAxis.isEnabled() || !mYAxis.isDrawLabelsEnabled())
            return;
        //1.获取position,转换在这个方法里
        float[] positions = getTransformedPositions();
        //2.设置paint属性
        mAxisLabelPaint.setTextSize(mYAxis.getTextSize());
        mAxisLabelPaint.setColor(mYAxis.getTextColor());
        //3.获取xOffset yOffset,计算出xPos(label的x起始绘制坐标)
        float xoffset = mYAxis.getXOffset();
        float yoffset = mYAxis.getYOffset() +Utils.calcTextHeight(mAxisLabelPaint, "A") / 2.5f;
        float xPos = 0f;
        mAxisLabelPaint.setTextAlign(Paint.Align.RIGHT);
        xPos =  mViewPortHandler.offsetLeft() - xoffset;
        //4.绘制labels

        drawYLabels(c,xPos,positions,yoffset);
    }

 protected float[] getTransformedPositions() {
        //使用buffer时因为不建议在onDraw方法中频繁的创建对象,*2 是每个点都是由x,y组成
        if (mGetTransformedPositionsBuffer.length != mYAxis.mEntryCount * 2) {
            mGetTransformedPositionsBuffer = new float[mYAxis.mEntryCount * 2];
        }
        float[] positions = mGetTransformedPositionsBuffer;
        for (int i = 0; i < positions.length; i += 2) {
            positions[i + 1] = mYAxis.mEntries[i / 2];
        }
        //映射
        mTrans.pointValuesToPixel(positions);
        return positions;
    }

我们再看一下pointValuesToPixel这个方法

/**
注意矩阵的顺序是:value-touch-offset,原因已经在前面的工具类中讲过了
**/
public void pointValuesToPixel(float[] pts) {

        mMatrixValueToPx.mapPoints(pts);
        mViewPortHandler.getMatrixTouch().mapPoints(pts);
        long s = System.currentTimeMillis();
        mMatrixOffset.mapPoints(pts);
        Log.e("mapPoints","count: "+ pts.length+",mapPoints time:" +(System.currentTimeMillis() - s));
    }

3.整个绘制流程

以BarChart为例简述坐标轴的整个绘制流程

3.1 初始化

在Chart 的 init方法中会初始化 Axis,Transform,Renderer,ViewPortHandler的实例

3.2 在notifyDataSetChanged方法中计算最大最小值,偏移量等

 @Override
    public void notifyDataSetChanged() {
        if (mData == null) {
            return;
        }
        if (mRenderer != null)
            mRenderer.initBuffers();
        //计算最大最小值
        calcMinMax();
        //计算坐标轴的labels
        mAxisRendererLeft.computeAxis(mAxisLeft.mAxisMinimum, mAxisLeft.mAxisMaximum, false);
        mXAxisRenderer.computeAxis(mXAxis.mAxisMinimum, mXAxis.mAxisMaximum, false);
        //计算偏移量
        calculateOffsets();
    }

computeAxis 方法上面已经介绍过了,下面看一下其他的两个方法

    protected void calcMinMax() {
        mXAxis.calculate(mData.getXMin(), mData.getXMax());
        // calculate axis range (min / max) according to provided data
        mAxisLeft.calculate(mData.getYMin(), mData.getYMax());
        //mAxisRight.calculate(mData.getYMin(), mData.getYMax());
        );
    }
//计算的content也就是data的offsets
public void calculateOffsets() {
    //1.开始都为0
        float offsetLeft = 0f, offsetRight = 0f, offsetTop = 0f, offsetBottom = 0f;
    //2.bottomOffset加上x轴的在y方向上的offset和labelHeight,y轴也类似,不过加的是labelWidth
        if (mXAxis.isEnabled() && mXAxis.isDrawLabelsEnabled()) {
            float xLabelHeight = mXAxis.mLabelRotatedHeight + mXAxis.getYOffset();
            offsetBottom += xLabelHeight;
        }

        if (mAxisLeft.isEnabled() && mAxisLeft.isDrawLabelsEnabled()) {
            float longestYLabelWidth = mAxisLeft.getRequiredWidthSpace(mAxisRendererLeft.getPaintAxisLabels());
            offsetLeft += longestYLabelWidth;
        }
//3.加上chart设置的offsets
        offsetTop += getExtraTopOffset();
        offsetRight += getExtraRightOffset();
        offsetBottom += getExtraBottomOffset();
        offsetLeft += getExtraLeftOffset();

        float minOffset = Utils.convertDpToPixel(mMinOffset);
//4.重置ViewPortHandler的可绘制区域
        mViewPortHandler.restrainViewPort(
                Math.max(minOffset, offsetLeft),
                Math.max(minOffset, offsetTop),
                Math.max(minOffset, offsetRight),
                Math.max(minOffset, offsetBottom));
//5.初始化Transformer得offsetMartrix和ValuePxMatrix,第一个matrix记录offsets,第二个matrix记录
        prepareOffsetMatrix();
        prepareValuePxMatrix();
    }

prepareOffsetMatrix 和prepareValuePxMatrix还是比较重要的,如下可以看到依次调用了Transformer的对应的方法,这两个方法已经在前面介绍Util的时候介绍过了

protected void prepareOffsetMatrix() {
        mLeftAxisTransformer.prepareMatrixOffset(false);
    }

 protected void prepareValuePxMatrix() {

        mLeftAxisTransformer.prepareMatrixValuePx(mXAxis.mAxisMinimum,
                mXAxis.mAxisRange,
                mAxisLeft.mAxisRange,
                mAxisLeft.mAxisMinimum);
    }

3.3 在onDraw方法中开始绘制坐标轴

 protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
     
        if (mData == null) return;
        long startTime = System.currentTimeMillis();
        
      //1.绘制X轴
        if (mXAxis.isEnabled()) {
            mXAxisRenderer.computeAxis(mXAxis.mAxisMinimum, mXAxis.mAxisMaximum, false);
        }
        
        mXAxisRenderer.renderAxisLine(canvas);
        mXAxisRenderer.renderGridLines(canvas);
        //绘制Y轴
        if (mAxisLeft.isEnabled()) {
            mAxisRendererLeft.computeAxis(mAxisLeft.mAxisMinimum, mAxisLeft.mAxisMaximum, false);
        }

        mAxisRendererLeft.renderAxisLine(canvas);
        mAxisRendererLeft.renderGridLines(canvas);
      ...
       //绘制X,Y的labels
        mXAxisRenderer.renderAxisLabels(canvas);
        mAxisRendererLeft.renderAxisLabels(canvas);
    }

可以看到,onDraw方法中每次都调用了Aixs的computeAxis方法重新计算了坐标轴,这是因为动画和手势操作后需要重新计算数据。

4.总结:

通过坐标轴的绘制流程,可以清晰的看出这个开源库的思路:

image.png
就是先计算出原始数据的坐标,然后利用三个矩阵映射成屏幕上的像素坐标,三个矩阵的作用重新总结一下:

1.mMatrixValueTopx:根据原始数据的最大最小值和屏幕上可绘制区域的rect计算出缩放比例,平移距离等,这样可以轻松的将一组原始数据经过缩放和平移就变成了屏幕上的数据。

2.mMatrixTouch:保存了手势操作造成的影响:缩放比例和拖拽距离。

3.mMatrixOffset:保存了根据设置的offsets以及绘制坐标轴所需要的空间计算出来的content区域的offsets。