修改MPAndroidChart实现斑马条填充色

2018-11-03  本文已影响36人  一线游骑兵
分析原因:最新一期的UI走势图颜色填充样式现有框架不支持。
分析目标:找到切入点并以最小的代价扩展现有框架满足需求。

现有折线图填充样式:


image.png

当前UI图填充样式:


image.png
走势图填充部分源码分析:

LineDataRender.drawData(Canvas c)

        LineData lineData = mChart.getLineData();

        for (ILineDataSet set : lineData.getDataSets()) {

            if (set.isVisible())
                drawDataSet(c, set);
        }

遍历该图表中所有折线,然后调用drawDataSet(c,set)来绘制每条折线,在drawDataSet(c,set)中会调用drawLinear(c, dataSet);方法:

  /**
     * Draws a normal line.
     *
     * @param c
     * @param dataSet
     */
    protected void drawLinear(Canvas c, ILineDataSet dataSet) {

        int entryCount = dataSet.getEntryCount();
        ...
        // if the data-set is dashed, draw on bitmap-canvas
        if (dataSet.isDashedLineEnabled()) {
            canvas = mBitmapCanvas;
        } else {
            canvas = c;
        }

        mXBounds.set(mChart, dataSet);

        // if drawing filled is enabled
        if (dataSet.isDrawFilledEnabled() && entryCount > 0) {
            drawLinearFill(c, dataSet, trans, mXBounds);
        }
        ...
        canvas.drawLines(mLineBuffer, 0, size, mRenderPaint);
    }

如果需要绘制填充,则绘制填充,进入drawLinearFill(c, dataSet, trans, mXBounds);方法中:

    protected void drawLinearFill(Canvas c, ILineDataSet dataSet, Transformer trans, XBounds bounds) {

        final Path filled = mGenerateFilledPathBuffer;
    
        final int startingIndex = bounds.min;       //获取该条折线的最小可见的x值
        final int endingIndex = bounds.range + bounds.min;  //获取该条折线最大可见的x值
        final int indexInterval = 128;      //设置一个最大的index

        int currentStartIndex = 0;
        int currentEndIndex = indexInterval;
        int iterations = 0;

        // Doing this iteratively in order to avoid OutOfMemory errors that can happen on large bounds sets.
        do {
            currentStartIndex = startingIndex + (iterations * indexInterval);
            currentEndIndex = currentStartIndex + indexInterval;
            currentEndIndex = currentEndIndex > endingIndex ? endingIndex : currentEndIndex;    //为了防止oom最多不超过128过

            if (currentStartIndex <= currentEndIndex) {     //从0不断的循环到最后点
                generateFilledPath(dataSet, currentStartIndex, currentEndIndex, filled);        //生成填充的路径

                trans.pathValueToPixel(filled);  //将Entry路径的值通过矩阵映射为屏幕像素值
                //对路径进行填充【可以纯颜色或者自定义的渐变等】
                final Drawable drawable = dataSet.getFillDrawable();
                if (drawable != null) {

                    drawFilledPath(c, filled, drawable);
                } else {

                    drawFilledPath(c, filled, dataSet.getFillColor(), dataSet.getFillAlpha());
                }
            }

            iterations++;

        } while (currentStartIndex <= currentEndIndex);
    }

通过上边的代码也可以看到,最终生成填充路径的实现在 generateFilledPath方法中:

    private void generateFilledPath(final ILineDataSet dataSet, final int startIndex, final int endIndex, final Path outputPath) {
        Utils.lgd("generateFilledPath >> startIndex: " + startIndex + " , endIndex: " + endIndex);
        final float fillMin = dataSet.getFillFormatter().getFillLinePosition(dataSet, mChart);      //获取到该条折线底边填充的统一y值
        final float phaseY = mAnimator.getPhaseY();
        final boolean isDrawSteppedEnabled = dataSet.getMode() == LineDataSet.Mode.STEPPED;

        final Path filled = outputPath;
        filled.reset();

        final Entry entry = dataSet.getEntryForIndex(startIndex);

        filled.moveTo(entry.getX(), fillMin);       //将路径起始点移动到:(x1,fillMin),即第一个点的x轴位置与底边的y值
        filled.lineTo(entry.getX(), entry.getY() * phaseY);     //然后连接到第一个点y值,自此填充图形的左边第一条线完成
        Utils.lgd(" >> moveTo: " + entry.getX() + "," + fillMin + ",  lineTo: " + entry.toString());
        // create a new path
        Entry currentEntry = null;
        Entry previousEntry = null;
        for (int x = startIndex + 1; x <= endIndex; x++) {  //不断轮询其他的点并lineTo到当前点的y轴位置,即连接所有点的y值

            currentEntry = dataSet.getEntryForIndex(x);

            filled.lineTo(currentEntry.getX(), currentEntry.getY() * phaseY);
            Utils.lgd(" --> " + currentEntry.toString());
            previousEntry = currentEntry;
        }

        // close up
        if (currentEntry != null) {     //最后,从最后一个点的x轴位置,从y值向下连接到底边的最小y值fillMin
            filled.lineTo(currentEntry.getX(), fillMin);
            Utils.lgd(" >> " + currentEntry.getX() + " , " + fillMin + " <> close...");
        }

        filled.close();     //闭合路径,path生成完成。
    }

ok,现在最后的一点就是fillMin值的生成,代码可知是由dataSet.getFillFormatter().getFillLinePosition(dataSet, mChart)类中的方法完成。点击进去是一个接口定义的方法:

/**
 * Interface for providing a custom logic to where the filling line of a LineDataSet
 * should end. This of course only works if setFillEnabled(...) is set to true.
 * 
 * @author Philipp Jahoda
 */
public interface IFillFormatter
{

    /**
     * Returns the vertical (y-axis) position where the filled-line of the
     * LineDataSet should end.
     * 
     * @param dataSet the ILineDataSet that is currently drawn
     * @param dataProvider
     * @return
     */
    float getFillLinePosition(ILineDataSet dataSet, LineDataProvider dataProvider);
}

根据注释可知,返回当前数据集结束线(filled-line)的水平y轴位置。通过源码查看,框架为所有LineChart设置了默认的实现类:
LineDataSet类:

    /**
     * formatter for customizing the position of the fill-line
     */
    private IFillFormatter mFillFormatter = new DefaultFillFormatter();
    ...
    public void setFillFormatter(IFillFormatter formatter) {

        if (formatter == null)
            mFillFormatter = new DefaultFillFormatter();
        else
            mFillFormatter = formatter;
    }

而通过public class DefaultFillFormatter implements IFillFormatter可知,该类提供了默认的底边y值的计算方法:代码如下:
DefaultFillFormatter:

    @Override
    public float getFillLinePosition(ILineDataSet dataSet, LineDataProvider dataProvider) {
        float fillMin = 0f;     
        float chartMaxY = dataProvider.getYChartMax();      //当前图表的最大y值
        float chartMinY = dataProvider.getYChartMin();      //当前图表的最小y值

        LineData data = dataProvider.getLineData();
        if (dataSet.getYMax() > 0 && dataSet.getYMin() < 0) {   
            //如果数据集中的最大值大于0并且数据集中的最小值小于0,则fillMin=0
            fillMin = 0f;
        } else {

            float max, min;

            if (data.getYMax() > 0) //如果数据集中的最大值大于0,则max=0
                max = 0f;   
            else                    //否则,max=图表中的最大y值
                max = chartMaxY;
            if (data.getYMin() < 0)     //如果图表最小y值小于0,则min=0
                min = 0f;
            else                    //否则,min=图表最小y值
                min = chartMinY;
            //如果图表最小y值>=0,则fillMin=min,否则fillMin=max;
            fillMin = dataSet.getYMin() >= 0 ? min : max;
        }
        return fillMin;
    }

总的来说fillMin的生成规则如下:

  1. 如果数据集中的最大值大于0并且最小值小于0,则fillMin=0;(三角,底边在下)
  2. 如果数据集最小值>0,即在x轴上方,则fillMin为图表最小y值,否则如果数据集最大y值大于0,则为0(情形如1),如果最大最小都小于0 ,即所有点都在x轴下方,则fillMin为数据集的最大y值,自此的底部边界在最上边,成为了倒三角。
    正三角:


    image.png

    倒三角:


    image.png
填充颜色的主要逻辑就是闭合路径的path形成过程,过程总结如下:
  1. 首先指定一个path,计算出底边最小的y值fillMin,然后moveTo(0,fillMin);
  2. 接着不断轮询该折线图中所有的点,并不断的lineTo到当前点的坐标值;
  3. 直到lineTo完所有的点的y轴坐标,最后在lineTo(xn,fillMin),其中xn代表该折线图中最后一个点的x轴坐标。然后再调用path.close进行路径闭合。

举个栗子:


image.png

path生成路径:假设fillMin=0 ,则:

  1. moveTo(0,fillMin)即moveTo(0,0);
  2. lineTo(0,2),即连接(0,0)--(0.2),左边界完成;
  3. lineTo(1,3);即连接(0,2)--(1,3),第一条折线完成;
  4. lineTo(2,3);即连接(1,3)--(2,3);第二条折线完成
  5. lineTo(3,4); 即连接(2,3)--(3.4);第三条折线完成;
  6. lineTo(4,5);即连接(3,4)--(4.5);上方最后一条折线完成;
  7. lineTo(5.0);右边界完成;
  8. 闭合路径path.close()

在调试过程中发现,无论是drawLinear还是其中的drawFill方法,并不是一次畅通的执行下来,而是会出现多次绘制相同的区域。drawFill中每次执行完成绘制后的前进步长基本也是在3个点左右。搞不明白为什么会出现这种情况,不断的重绘应该是很费资源的,当前的猜测可能是为了考虑x轴y轴的显示动画。

问题分析:通过分析现有框架逻辑,无论怎样,一条折线的底边一定是一个固定的值,即底边一定是一条直线,而我们的需求是将底边绘制成特定的曲线。因此需要寻找切入点进行扩展。通过需求UI图可知,每个点的fillMin是可变的,即每个点的fillMin是根据该条折线的下边的一条折线的点的y值决定的,但是通过阅读源码发现,如果图表包含多条折线,会循环进行绘制,并且每一条path只会生成一个固定的fillMin,因此系统暴露的扩展方法,即继承IFillFormatter类并实现其方法是无法满足需求的。因此需要跳到生成路径的上一个方法中自己计算路径并根据需求给每个路径填充不同的颜色。因此,该条折线的填充path需要用到该折线下方折线中的路径进行生成,最下边一条折线不进行填充。或者我们可以在闭合前的一步操作用一个列表进行反向lineTo。Just Try it!

在负责渲染折线图的类LineChartRender中的drawLinear方法中添加代码如下:

  LineData lineData = mChart.getLineData();
            int lineDataSize = lineData.getDataSetCount();
            List<Path> fillPathList = new ArrayList<>();
            for (int i = 0; i < lineDataSize - 1; i++) {
                Path path = new Path();
                ILineDataSet bottomSet = lineData.getDataSetByIndex(i);
                Entry bottomStartPoint = bottomSet.getEntryForIndex(0);   //起始点
                path.moveTo(bottomStartPoint.getX(), bottomStartPoint.getY());   //移动到起始点
                for (int j = 1; j < bottomSet.getEntryCount(); j++) {
                    path.lineTo(bottomSet.getEntryForIndex(j).getX(), bottomSet.getEntryForIndex(j).getY());
                }
                ILineDataSet topSet = lineData.getDataSetByIndex(i + 1);
                int topSetCount = topSet.getEntryCount();
                for (int k = topSetCount - 1; k >= 0; k--) {    //反向lineTo
                    path.lineTo(topSet.getEntryForIndex(k).getX(), topSet.getEntryForIndex(k).getY());
                }
                path.close();   //闭合路径
                trans.pathValueToPixel(path);   //将Entry值通过矩阵映射为屏幕像素点数
                fillPathList.add(path); //添加到集合中
            }
            int pathSize = fillPathList.size();
            Paint paint = new Paint();
            paint.setAntiAlias(true);
            paint.setAlpha(80);
            paint.setStyle(Paint.Style.FILL);
            for (int i = 0; i < pathSize; i++) {
                if (i % 2 == 0) {
                    paint.setColor(Color.parseColor("#143870ff"));
                } else {
                    paint.setColor(Color.parseColor("#263870ff"));
                }
                c.drawPath(fillPathList.get(i), paint);
            }

最终效果如下:


image.png
上边只是一个初步的实现效果,具体的实现细节,如向外暴露的控制方法,颜色值参数的传递等需要再结合框架本身的特性以最小的入侵性来实现【扩展开放,修改关闭】。
上一篇下一篇

猜你喜欢

热点阅读