自定义view:快速实现柱状图的绘制


1、前言

本文讲解如何通过canvas快速实现柱状图表的绘制,先看下最终效果图:

网上开源的图表绘制框架还是很多的,功能也非常强大,比如hellocharts,MPAndroidCharts等。

但是作为一个有追求的程序员,过多地使用第三方的东西总是觉得缺少点安全感。主要原因是现成的轮子固然好,但是仅仅作为使用者的我们对其理解不深,导致不易于扩展修改。尤其是涉及自定义view这种效果显示的,保不准哪天产品和设计就把效果给改了,然后我们只能苦逼的研究源码,这样太被动了。而自己写的东西就不一样了,全在自己掌控之下,任你需求设计随便改,我自横刀对你笑。

2、分析

整个柱状图的绘制可以分为三个步骤:

  1. 坐标轴绘制
  2. 绘制标尺
  3. 刻度值绘制
  4. 柱状绘制

3、代码实现

3.1 坐标轴的绘制

坐标轴的绘制使用的是cavans的drawLine方法:

drawLine(float startX, float startY, float stopX, float stopY,Paint paint)

其中传入的参数分别是起始坐标、终点坐标、画笔。俩点妥妥的确认一条直线,所以我们知道俩个绘制坐标点就行。

x轴和y轴都是从原点(坐标轴的原点,而得view的,请勿混淆了)开始画,为了确认这个点,我们全局定义了一个变量mPaddingLabLine表示坐标轴到view边界(Y轴就是到左边,x就是相对于底部)的距离,这个距离我们是用来绘制坐标轴刻度以及标题。涉及到位置计算时,为了兼容性我们去除view的内边距。这个原点的x、y坐标计算方法代码如下:

    float startX = getPaddingLeft() + mPaddingLabLine;
    float startY = getHeight() - getPaddingBottom() - mPaddingLabLine;

x轴终点坐标很好理解,y方向不变与起始点相等,x方向宽度减去内边距即可:

    float stopX = getWidth() - getPaddingRight();
    float stopY = startY;

y轴终点坐标与x轴的类似,只是此时x点坐标没变,y点变成了内边距,起始点计算出来了后,线条的绘制就很简单了:

   canvas.drawLine(startX, startY, stopX, stopY, mTabLinePaint); //x轴
   canvas.drawLine(startX, startY, startX, getPaddingTop(), mTabLinePaint); //y轴

3.2 标尺绘制

标尺这部分也是绘制直线,在图表上绘制mLabelCount条直线作为标尺线.

    float height = startY;
    float ceilHeight = (mHeight - mAxisTitleSize) / mLabelCount;
    for (int i = 0; i < mLabelCount; i++)
    {
        canvas.save();
        canvas.translate(0, -ceilHeight * i);
        canvas.drawLine(startX, startY, stopX, stopY, mTabLinePaint);
        canvas.restore();
    }

我们计算根据标尺数目计算出每根标尺线之间的距离ceilHeight,每次绘制标尺线时,只需要在Y方向平移画布就行。可以理解为绘制一与x轴重叠的直线,然后往上平移,这样逻辑上更简洁。

3.3 刻度值绘制

刻度值包括俩个,一个是坐标轴名称,比如Y轴的销售额,x轴的年份等,另一个是每一个刻度的具体值。

坐标轴名称绘制

x轴相对简单一点,直接调用cavans的drawText方法即可。这里主要分析下y轴的画法,因为y轴名称文字是竖着显示,所以绘画的时候需要把画布逆时针旋转90度,画布旋转注意中心点的位置为文字绘制的位置,但是需要稍微右移一点,防止文字左偏导致部分内容不可见:

    canvas.save();
    canvas.rotate(-90, getPaddingLeft(), mHeight / 3 - textHeight);
    canvas.drawText(axisTitle, getPaddingLeft(), mHeight / 3, mTabLinePaint);
    canvas.restore();
坐标轴刻度值绘制

绘制刻度值的关键代码:

canvas.drawText(axisName[i], pointFs[i].x, pointFs[i].y, mTabLinePaint);

其中axisName表示需要绘制的坐标刻度值,pointFs表示每个刻度值文字绘制时的坐标点数组,俩个坐标轴的计算方法不同,先看x轴:

    int textHeight = getTextHeight();
    int cellWidth = (mWidth - mPaddingLabLine - mColumnMarginRight) / this.mColumnName.length;
    final int y = mHeight - mPaddingLabLine + textHeight;
    for (int i = 0; i < pointFs.length; i++)
    {
        float textWidth = mTabLinePaint.measureText(axisName[i]);
        PointF pointF = new PointF();
        pointF.x = mPaddingLabLine + mColumnMarginRight + i * cellWidth + textWidth / 2;
        pointF.y = y;
        pointFs[i] = pointF;
    }

根据x轴刻度值数目把x轴平分为宽度为cellWidth的若干等分,mColumnMarginRight的含义为,第一根柱子从原点向像右偏移的距离,防止第一根柱子紧挨Y轴。为了保证每个刻度值都在柱子的正中间,每次计算该刻度的x位置时,加上该刻度值宽度的一半。所有X轴刻度值得y坐标都是一样的,x轴加上文字高度即可textHeight


接下来是Y轴刻度值绘制:

        float height = getHeight() - getPaddingBottom() - mPaddingLabLine;
        float ceilHeight = (mHeight - mAxisTitleSize) / mLabelCount;

        for (int i = 0; i < pointFs.length; i++)
        {
            PointF pointF = new PointF();
            float textWidth = mTabLinePaint.measureText(axisName[i]);
            pointF.x = mPaddingLabLine - textWidth - mAxisTextDistance / 3;
            pointF.y = height - i * ceilHeight;
            pointFs[i] = pointF;
        }
    }

Y轴刻度值得计算与X轴类似,有个小细节注意一下就好了,可能刻度值文字个数不一样,按drawText方法又是从左开始画的(刻度值左对齐),这样的显示效果非常丑,要让刻度值右对齐的话就必须测量文字的宽度,然后Y轴减去这个宽度就可以,这样就紧靠Y轴,所以再减去mAxisTextDistance / 3就可以实现Y轴刻度值右对齐并且左偏移Y轴一定距离。


getTextHeight()测量字体的绘制高度:

//获取字符串的高度
private int getTextHeight()
{
    Paint.FontMetricsInt fontMetrics = mTabLinePaint.getFontMetricsInt();
    return fontMetrics.bottom - fontMetrics.top;
}

3.4 绘制柱型

先上代码

    //每一条数据占有的宽度
    int cellWidth = (int) (mWidth - mPaddingLabLine - mColumnMarginRight) / mColumnData.length;
    cellWidth = cellWidth > mMaxSubcolumnWidth ? mMaxSubcolumnWidth : cellWidth; //限制柱状图的最大宽度,防止列数过少时柱形图过宽

    //矩形区有效绘制高度
    float height = getHeight() - getPaddingBottom() - mPaddingLabLine;
    float proportion = height / (maxValue - minValue); //高度数值比例尺

    mTabLinePaint.setStyle(Paint.Style.FILL);
    for (int j = 0; j < mColumnData.length; j++)
    {
        int startPos = mPaddingLabLine + mColumnMarginRight + cellWidth * j;
        float top =height - proportion * (mColumnData[j] - minValue);
        RectF rectF = new RectF(startPos + cellWidth / 5, top, startPos + cellWidth * 4 / 5, height);

        canvas.drawRect(rectF, mTabLinePaint);
    }

4、完整代码

public class ColumnView extends View
{
    private int mWidth;
    private int mHeight;

    private final static int X_LAB = 0; //表示x轴绘制
    private final static int Y_LAB = 1; //y轴的绘制

    private Paint mTabLinePaint = new Paint(Paint.ANTI_ALIAS_FLAG);

    private int color_label_line = Color.parseColor("#DDDDDD"); //线条颜色
    private int mLineStroke = 1;//线条宽度
    private int mLabelCount = 10; //标尺线条数目

    /**
     * 图标所能显示的数值范围,
     * 最大值比能显示的极值大20%,防止如果出现较大数值时,矩形过高而过于贴近顶部
     */
    private float maxValue = 100 * 1.2f;
    private float minValue = 20;

    private float[] mColumnData;    //模拟数据

    private String mAxisXTitle = "销售项"; //x轴含义
    private String mAxisYTitle = "销售额"; //y轴含义

    private String[] mColumnName; //每个矩形数据项名称,即x轴刻度值
    private String[] mAxisYName;  //y轴刻度值

    private int mAxisTitleSize = DensityUtils.dp2px(getContext(), 12); //坐标轴标题大小
    private int mAxisNameTextSize = DensityUtils.dp2px(getContext(), 10); //坐标轴刻度值文字大小
    private int mAxisTextDistance = DensityUtils.dp2px(getContext(), 6); // 坐标轴标题文字与刻度值之间的距离
    private int mColumnMarginRight = DensityUtils.dp2px(getContext(), 3); // 矩形离Y轴的距离

    private int mMaxSubcolumnWidth = DensityUtils.dp2px(getContext(), 40);

    //坐标轴到view边界的留出的空间,用于绘制文字
    private int mPaddingLabLine = mAxisTitleSize + mAxisNameTextSize + 2 * mAxisTextDistance;


    public ColumnView(Context context)
    {
        this(context, null);
    }

    public ColumnView(Context context, @Nullable AttributeSet attrs)
    {
        this(context, attrs, 0);
    }

    public ColumnView(Context context, @Nullable AttributeSet attrs, int defStyleAttr)
    {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init()
    {
        mTabLinePaint.setColor(color_label_line);
        mTabLinePaint.setStrokeWidth(mLineStroke);

        initData();
    }

    private void initData()
    {
        //生成数据
        mColumnData = new float[8];
        for (int i = 0; i < mColumnData.length; i++)
        {
            mColumnData[i] = new Random().nextInt(80) + 20;
        }

        //Y轴刻度
        mColumnName = new String[8];
        for (int i = 0; i < mColumnData.length; i++)
        {
            mColumnName[i] = "数据" + String.valueOf(i + 1);
        }

        //y轴刻度
        mAxisYName = new String[mLabelCount];
        int v = (int) ((maxValue - minValue) / mLabelCount * 100);
        float value = v / 100f; //转成精确小数点后俩位的小数
        for (int i = 0; i < mAxisYName.length; i++)
        {
            String axisName = value * i + minValue + "";
            mAxisYName[i] = axisName;
        }
    }

    @Override
    public void layout(int l, int t, int r, int b)
    {
        super.layout(l, t, r, b);

        int width = getWidth();
        int height = getHeight();

        //兼容性处理,获取view的可绘制区域的宽高
        mWidth = width - getPaddingLeft() - getPaddingRight();
        mHeight = height - getPaddingBottom() - getPaddingTop();
    }

    @Override
    protected void onDraw(Canvas canvas)
    {
        super.onDraw(canvas);

        canvas.drawColor(Color.WHITE);

        drawLabLine(canvas);
        drawAxis(canvas);
        drawSubcolumn(canvas);
    }

    //画柱状矩形
    private void drawSubcolumn(Canvas canvas)
    {
        mTabLinePaint.setColor(Color.parseColor("#4476AB"));

        //每一条数据占有的宽度
        int cellWidth = (int) (mWidth - mPaddingLabLine - mColumnMarginRight) / mColumnData.length;
        cellWidth = cellWidth > mMaxSubcolumnWidth ? mMaxSubcolumnWidth : cellWidth; //限制柱状图的最大宽度

        //矩形区有效绘制高度
        float height = getHeight() - getPaddingBottom() - mPaddingLabLine;
        float proportion = height / (maxValue - minValue); //高度数值比例尺

        mTabLinePaint.setStyle(Paint.Style.FILL);
        for (int j = 0; j < mColumnData.length; j++)
        {
            int startPos = mPaddingLabLine + mColumnMarginRight + cellWidth * j;
            float top =height - proportion * (mColumnData[j] - minValue);
            RectF rectF = new RectF(startPos + cellWidth / 5, top, startPos + cellWidth * 4 / 5, height);

            canvas.drawRect(rectF, mTabLinePaint);
        }
    }

    //画刻度值
    private void drawAxis(Canvas canvas)
    {
        drawAxisTitle(canvas, mAxisYTitle, Y_LAB);
        drawAxisTitle(canvas, mAxisXTitle, X_LAB);

        drawAxisName(canvas, Y_LAB, mAxisYName);
        drawAxisName(canvas, X_LAB, mColumnName);
    }

    // 画刻度值
    private void drawAxisName(Canvas canvas, int labelType, String[] axisName)
    {
        mTabLinePaint.setTextSize(mAxisNameTextSize);

        //计算绘制坐标
        PointF[] pointFs = calculateAxisNamePosition(labelType, axisName);

        for (int i = 0; i < axisName.length; i++)
        {
            canvas.drawText(axisName[i], pointFs[i].x, pointFs[i].y, mTabLinePaint);
        }
    }

    @NonNull
    private PointF[] calculateAxisNamePosition(int labelType, String[] axisName)
    {
        PointF[] pointFs = new PointF[axisName.length];
        if (labelType == X_LAB)  //x轴
        {
            int textHeight = getTextHeight();

            int cellWidth = (mWidth - mPaddingLabLine - mColumnMarginRight) / this.mColumnName.length;
            final int y = mHeight - mPaddingLabLine + textHeight;
            for (int i = 0; i < pointFs.length; i++)
            {
                float textWidth = mTabLinePaint.measureText(axisName[i]);
                PointF pointF = new PointF();
                pointF.x = mPaddingLabLine + mColumnMarginRight + i * cellWidth + textWidth / 2;
                pointF.y = y;
                pointFs[i] = pointF;
            }
        } else
        {
            float height = getHeight() - getPaddingBottom() - mPaddingLabLine;
            float ceilHeight = (mHeight - mAxisTitleSize) / mLabelCount;

            for (int i = 0; i < pointFs.length; i++)
            {
                PointF pointF = new PointF();
                float textWidth = mTabLinePaint.measureText(axisName[i]);
                pointF.x = mPaddingLabLine - textWidth - mAxisTextDistance / 3;
                pointF.y = height - i * ceilHeight;
                pointFs[i] = pointF;
            }
        }
        return pointFs;
    }

    //绘制坐标轴名称
    private void drawAxisTitle(Canvas canvas, String axisTitle, int labelType)
    {
        mTabLinePaint.setTextSize(mAxisTitleSize);
        int textHeight = getTextHeight();
        if (labelType == X_LAB)
        {
            //x轴
            canvas.drawText(mAxisXTitle, mWidth / 2, mHeight - textHeight + mAxisTextDistance, mTabLinePaint);
        } else
        {
            //y轴
            canvas.save();
            //注意中心点的位置,需要稍微右移一点,防止文字太过于左偏
            canvas.rotate(-90, getPaddingLeft(), mHeight / 3 - textHeight);
            canvas.drawText(axisTitle, getPaddingLeft(), mHeight / 3, mTabLinePaint);
            canvas.restore();
        }
    }

    //获取字符串的高度
    private int getTextHeight()
    {
        Paint.FontMetricsInt fontMetrics = mTabLinePaint.getFontMetricsInt();
        return fontMetrics.bottom - fontMetrics.top;
    }

    //画坐标轴
    private void drawLabLine(Canvas canvas)
    {
        mTabLinePaint.setColor(color_label_line);
        //x轴起始点(也是Y轴的起点),终点
        float startX = getPaddingLeft() + mPaddingLabLine;
        float startY = getHeight() - getPaddingBottom() - mPaddingLabLine;
        float stopX = getWidth() - getPaddingRight();
        float stopY = startY;

        canvas.drawLine(startX, startY, stopX, stopY, mTabLinePaint); //x轴
        canvas.drawLine(startX, startY, startX, getPaddingTop(), mTabLinePaint); //y轴

        //画标尺
        float height = startY;
        float ceilHeight = (mHeight - mAxisTitleSize) / mLabelCount;
        for (int i = 0; i < mLabelCount; i++)
        {
            canvas.save();
            canvas.translate(0, -ceilHeight * i);
            canvas.drawLine(startX, startY, stopX, stopY, mTabLinePaint);
            canvas.restore();
        }
    }
}

5、总结

在xml文件中的使用与普通view用法相同:

    <widget.qike.com.columnview.widget.ColumnView
        android:id="@+id/cv_test"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

demo下载地址

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,393评论 5 467
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,790评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,391评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,703评论 1 270
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,613评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,003评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,507评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,158评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,300评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,256评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,274评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,984评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,569评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,662评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,899评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,268评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,840评论 2 339

推荐阅读更多精彩内容