自定义简易折线图

前言

最近项目中的一个功能是要求绘制出一个简易的折线图来显示指定日期内的交易量数量以及变化趋势。于是在GitHub 上查了一堆库,但大都显得有些大材小用。所以最后考虑自定义 View 来实现这一功能,顺便补足一下自己一直以来的短板。
注:本文参考自 『Android 自定义View -- 简约的折线图』 一文

首先看一下运行效果:


自定义折线图.png

实现步骤

整个绘制过程大致分为四步:

  1. 绘制坐标轴
  2. 依次绘制各个坐标点
  3. 连线
  4. 在每个点上画出具体数值

首先定义一个类 CustomLineChart 继承自 View, 重写其构造方法。 这里由于自己偷懒并没有自定义属性,而是将属性值如 X 轴和 Y 轴刻度值通过外部方法传进来,所以构造方法很简单。

public class CustomLineChart extends View {

    public CustomLineChart(Context context) {
        super(context);
    }

    public CustomLineChart(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public CustomLineChart(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

}

onMeasure 测量大小

这里我指定了宽高的 MeasureSpec 只能为 EXACTLY 模式,如果为 AT_MOST 模式直接抛出异常,即宽高必须为具体数值,而不是 wrap_content 。

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int widthMode = MeasureSpec.getMode(widthMeasureSpec);
    int widthSize = MeasureSpec.getSize(widthMeasureSpec);
    int heightMode = MeasureSpec.getMode(heightMeasureSpec);
    int heightSize = MeasureSpec.getSize(heightMeasureSpec);

    if (widthMode == MeasureSpec.EXACTLY) {
        mWidth = widthSize;
    } else if (widthMode == MeasureSpec.AT_MOST) {
        throw new IllegalArgumentException("view宽度必须指定! ");
    }

    if (heightMode == MeasureSpec.EXACTLY) {
        mHeight = heightSize;
    } else if (heightMode == MeasureSpec.AT_MOST) {
        throw new IllegalArgumentException("view高度必须指定! ");
    }

    setMeasuredDimension(mWidth, mHeight);
}

再来复习下 MeasureSpec 的定义:

measure 方法中的两个参数的类型就是 MeasureSpec,而 MeasureSpec 是父控件对子控件宽高的期望。它是一个 32 位的 int 类型的数,前两位代表测量模式 SpecMode ,后 30 位代表测量大小 SpecSize。

SpecMode 一共有三种测量模式—— EXACTLY、 AT_MOST、 UNSPECIFIDE:
a). EXACTLY 精确地测量模式,xml 文件中写 200dp 、match_parent 等代表使用 EXACTLY 测量模式。
b). AT_MOST 最大模式。xml 文件中写 wrap_content 表示使用 AT_MOST 测量模式。
c). UNSPECIFIED,它表示的测量模式是无限大,就是你想要多大就多大,我们只在绘制特定情况的自定义 view 才用得到此模式。

onDraw 绘制图形

接下来就到了最复杂的绘制过程了,一步步来。
首先处理几种异常情况,即 X 或 Y 轴未传值以及未传坐标点的集合的情况:

if(mXAxis.length==0||mYAxis.length==0){  
    throw new IllegalArgumentException("X 或 Y 轴为空,请赋值!");
}
if (mPointMap == null || mPointMap.size() == 0) {
    int textLength = (int) axisPaint.measureText(mNoDataMsg);
    canvas.drawText(mNoDataMsg, mWidth/2 - textLength/2, mHeight/2, axisPaint);
}

上述值 mXAxis、mYAxis、mPointMap 分别为 X 轴、Y 轴和点的坐标集合。当未传坐标集合时,在 View 中央绘制文字提醒。

异常处理完之后,就开始绘制步骤了,首先来画 Y 轴。首先获取 Y 轴刻度的间距:

int yInterval = (int) ((mHeight - mYAxisFontSize - 2) / defaultYItem);// 计算Y轴 每个刻度的间距

上述 mHeight 为控件高度,mYAxisFontSize 为刻度值字体大小,defaultYItem 是我定义的一个静态变量,表示默认的 Y 轴数值个数.

由于项目中交易量数据有几种类型,且不同时间段查询结果差别很大,可能是个位数,也可能是五位数。所以这里我不能把 Y 轴刻度写死,只能根据后台获取到的值取其最大、最小,从而计算刻度差值。

// axisPaint 画坐标轴
Paint axisPaint = new Paint();
axisPaint.setTextSize(mYAxisFontSize);
axisPaint.setFakeBoldText(true);
axisPaint.setColor(Color.parseColor("#3F51B5"));

for (int i = 0; i < defaultYItem; i++) {
    // y轴刻度差值为 (maxY - minY) / (defaultYItem - 1)
    int y = maxY - ((maxY - minY) / (defaultYItem - 1)) * i;
    mYAxis = new ArrayList<>();
    mYAxis.add(String.valueOf(y));
    canvas.drawText(String.valueOf(y), 0, mYAxisFontSize + i * yInterval, axisPaint);
}

画完 Y 轴继续画 X 轴:

int[] xPoints = new int[mXAxis.size()];// x轴的刻度集合
int xItemX = (int) axisPaint.measureText(mYAxis.get(0));// 计算Y轴开始的第一个点坐标
int xOffset = 70;// X轴偏移量
float xInterval = (mWidth - xOffset) / ((float) (mXAxis.size()));// 计算x轴刻度间距
int xItemY = (int) (mYAxisFontSize + defaultYItem * yInterval);// 获取X轴刻度Y坐标

for (int i = 0; i < mXAxis.size(); i++) {
    canvas.drawText(mXAxis.get(i), i * xInterval + xItemX + xOffset, xItemY, axisPaint);
    xPoints[i] = (int) (i * xInterval + xItemX + axisPaint.measureText(mXAxis.get(i)) / 2 + xOffset);
}

然后就是画每一个坐标点了,这个稍微麻烦些。
因为屏幕左上角坐标为(0,0),所以计算每一个点的 Y 轴坐标应该由 map.get(i) 的值与最大值 maxY 的差值所占总差 (maxY - minY) 百分比,然后与纵坐标刻度所占高度 (mYAxisFontSize + (defaultYItem - 1) * yInterval) 相乘,再减去 Y 的偏移量 yOffset,得到坐标点的 Y 值。X 值好说,就是 xPoints[i]。

然后遍历 map 用 canvas.drawCircle 把每个点画出来,用 canvas.drawLine 将所有点连接起来。至此就差不多大功告成啦!

// 画点画线
int yOffset = -8;// 点和线 Y 值偏移量
Paint pointPaint = new Paint();
pointPaint.setColor(mLineColor);
pointPaint.setStyle(Paint.Style.FILL);
Paint linePaint = new Paint();
linePaint.setColor(mLineColor);
linePaint.setAntiAlias(true);
linePaint.setStrokeWidth(mStrokeWidth);

for (int i = 0; i < mXAxis.size(); i++) {
    if (mPointMap.get(i) == null) {
        throw new IllegalArgumentException("PointMap has incomplete data!");
    }
    // 画点画线
    float pointY = ((maxY - mPointMap.get(i)) / (float) (maxY - minY)) *
            (mYAxisFontSize + (defaultYItem - 1) * yInterval) - yOffset;// 点的纵坐标
    canvas.drawCircle(xPoints[i], pointY, mPointRadius, pointPaint);
    if (i > 0) {
        canvas.drawLine(xPoints[i - 1], ((maxY - mPointMap.get(i - 1)) / (float) (maxY - minY)) * (mYAxisFontSize + (defaultYItem - 1) * yInterval) - yOffset,
                xPoints[i], pointY, linePaint);
    }
}

最后为了让数据看起来更直观,我在每个点上加了具体数值的文本,用醒目的颜色绘制出来。
刚开始我为了方便把画数值文本的代码也放到上面的画点画线 for 循环中,结果发现后面的线会覆盖前面的点的数值文本,所以只有在后面再写一个 for 循环了,这样就能达到数值文本覆盖线条的效果。

// 在点上方或下方(根据点在折线图中的高度判断)drawText 画出点的具体数值
for (int i = 0; i < mXAxis.size(); i++) {
    float pointY = ((maxY - mPointMap.get(i)) / (float) (maxY - minY)) *
            (mYAxisFontSize + (defaultYItem - 1) * yInterval) - yOffset;// 点的纵坐标
    int textInterval = 0;// 点的数值与点的Y值间距
    if (pointY > (float) mHeight / 2) {
        textInterval = -30;
    } else {
        textInterval = 40;
    }
    canvas.drawText(mPointMap.get(i).toString(), xPoints[i] - 20,
            pointY + textInterval, pointTextPaint);
}

为了让文本绘制高度看起来更协调,上面我简单粗暴的做了个判断,如果点的纵坐标大于高度的一半,则文本显示在点的下方,否则显示在点的上方。如果你要问我为啥一个是 -30,一个是 40,大小不一样啊?那是因为文本字体本身也有高度啊!当然我这样偷懒直接写死不够严谨,正确做法应该是获取字体所占高度,然后计算差值。

设置参数

最麻烦的绘制部分完了,接下来就要提供设置参数方法了,本例中我给了四个参数,分别是 X 轴刻度值 List、交易量最大值 maxY、最小值 minY 以及点的键值对 mPointMap。

/* 设置X轴数值 */
public void setXItem(List<String> xItem) {
    mXAxis = xItem;
}

/* 给坐标点map集合赋值 */
public void setData(HashMap<Integer, Integer> data) {
    mPointMap = data;
    invalidate();
}

public void setMaxY(int maxY) {
    this.maxY = maxY;
}

public void setMinY(int minY) {
    this.minY = minY;
}

使用方法

由于没有使用自定义的属性,所以 xml 中很简单,给个宽高就 ok 了

<com.qishun.customlinechart.CustomLineChart
        android:id="@+id/mLineChart"
        android:layout_width="match_parent"
        android:layout_height="300dp">
</com.qishun.customlinechart.CustomLineChart>

然后在 Activity 中赋值

mChart = (CustomLineChart) findViewById(R.id.mLineChart);
List<String> xList = new ArrayList<>();
for (int i = 1; i <= 10; i++) {
    xList.add(String.valueOf(i));
}
mChart.setXItem(xList);
mChart.setMinY(35);
mChart.setMaxY(530);

HashMap<Integer, Integer> pointMap = new HashMap<>();
for (int i = 0; i < xList.size(); i++) {
    pointMap.put(i, (int) (Math.random() * 500));
}
mChart.setData(pointMap);

这里 X 轴数值我是写死的 1 到 10,点的 Y 值由随机数组成。而在实际项目中,X 轴是获取的后台的时间点字符串,点的坐标由时间点和交易量具体数值组成。

总结

至此基本的功能就都实现了,但是依然比较粗糙,如果想要更严谨的话就应该把各个字体的大小精确值计算进去,把几个属性值设计成自定义属性。

CustomLineChart 完整代码如下:

public class CustomLineChart extends View {
    private static int defaultYItem = 5;// 默认Y轴数值个数

    private int maxY;// Y轴最大值,外部通过 set 方法传进来
    private int minY;// Y轴最小值,外部通过 set 方法传进来

    private int mWidth, mHeight;// View 的宽和高
    private float mPointRadius = 10;// 点的半径
    private float mStrokeWidth = 8.0f;// 线条的宽度
    private float pointTextSize = 30;// 点上方数值字体大小
    private float mYAxisFontSize = 30;// Y轴字体的大小
    private int mLineColor = Color.parseColor("#00BCD4");// 折线的颜色

    private List<String> mXAxis;// X轴刻度值
    private List<String> mYAxis;// Y轴刻度值
    private HashMap<Integer, Integer> mPointMap;// 点的集合
    private String mNoDataMsg = "no data";// 没有数据的时候的内容

    public CustomLineChart(Context context) {
        super(context);
    }

    public CustomLineChart(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public CustomLineChart(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);

        if (widthMode == MeasureSpec.EXACTLY) {
            mWidth = widthSize;
        } else if (widthMode == MeasureSpec.AT_MOST) {
            throw new IllegalArgumentException("view宽度必须指定! ");
        }

        if (heightMode == MeasureSpec.EXACTLY) {
            mHeight = heightSize;
        } else if (heightMode == MeasureSpec.AT_MOST) {
            throw new IllegalArgumentException("view高度必须指定! ");
        }

        setMeasuredDimension(mWidth, mHeight);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        // axisPaint 画坐标轴
        Paint axisPaint = new Paint();
        axisPaint.setTextSize(mYAxisFontSize);
        axisPaint.setFakeBoldText(true);
        axisPaint.setColor(Color.parseColor("#3F51B5"));

        if (mPointMap == null || mPointMap.size() == 0) {
            int textLength = (int) axisPaint.measureText(mNoDataMsg);
            canvas.drawText(mNoDataMsg, mWidth / 2 - textLength / 2, mHeight / 2, axisPaint);
        } else {
            /* 画 Y 轴 */
            int yInterval = (int) ((mHeight - mYAxisFontSize - 2) / defaultYItem);// 计算Y轴每个刻度的间距
            for (int i = 0; i < defaultYItem; i++) {
                // y轴刻度差值为 (maxY - minY) / (defaultYItem - 1)
                int y = maxY - ((maxY - minY) / (defaultYItem - 1)) * i;
                mYAxis = new ArrayList<>();
                mYAxis.add(String.valueOf(y));
                canvas.drawText(String.valueOf(y), 0, mYAxisFontSize + i * yInterval, axisPaint);
            }

            /* 画 X 轴 */
            int[] xPoints = new int[mXAxis.size()];// x轴的刻度集合
            int xItemX = (int) axisPaint.measureText(mYAxis.get(0));// 计算Y轴开始的第一个点坐标
            int xOffset = 70;// X轴偏移量
            float xInterval = (mWidth - xOffset) / ((float) (mXAxis.size()));// 计算x轴刻度间距
            int xItemY = (int) (mYAxisFontSize + defaultYItem * yInterval);// 获取X轴刻度Y坐标

            for (int i = 0; i < mXAxis.size(); i++) {
                canvas.drawText(mXAxis.get(i), i * xInterval + xItemX + xOffset, xItemY, axisPaint);
                xPoints[i] = (int) (i * xInterval + xItemX + axisPaint.measureText(mXAxis.get(i)) / 2 + xOffset);
            }

            // 画点画线
            int yOffset = -8;// 点和线 Y 值偏移量
            Paint pointPaint = new Paint();
            pointPaint.setColor(mLineColor);
            pointPaint.setStyle(Paint.Style.FILL);
            Paint linePaint = new Paint();
            linePaint.setColor(mLineColor);
            linePaint.setAntiAlias(true);
            linePaint.setStrokeWidth(mStrokeWidth);

            // 每一个点上方的数值
            Paint pointTextPaint = new Paint();
            pointTextPaint.setTextSize(pointTextSize);
            pointTextPaint.setColor(Color.parseColor("#FF4081"));
            pointTextPaint.setFakeBoldText(true);

            for (int i = 0; i < mXAxis.size(); i++) {
                if (mPointMap.get(i) == null) {
                    throw new IllegalArgumentException("PointMap has incomplete data!");
                }
                // 画点画线
                float pointY = ((maxY - mPointMap.get(i)) / (float) (maxY - minY)) *
                        (mYAxisFontSize + (defaultYItem - 1) * yInterval) - yOffset;// 点的纵坐标
                canvas.drawCircle(xPoints[i], pointY, mPointRadius, pointPaint);
                if (i > 0) {
                    canvas.drawLine(xPoints[i - 1], ((maxY - mPointMap.get(i - 1)) / (float) (maxY - minY)) * (mYAxisFontSize + (defaultYItem - 1) * yInterval) - yOffset,
                            xPoints[i], pointY, linePaint);
                }
            }
            // 在点上方或下方(根据点在折线图中的高度判断)drawText 画出点的具体数值
            for (int i = 0; i < mXAxis.size(); i++) {
                float pointY = ((maxY - mPointMap.get(i)) / (float) (maxY - minY)) *
                        (mYAxisFontSize + (defaultYItem - 1) * yInterval) - yOffset;// 点的纵坐标
                int textInterval = 0;// 点的数值与点的Y值间距
                if (pointY > (float) mHeight / 2) {
                    textInterval = -30;
                } else {
                    textInterval = 40;
                }
                canvas.drawText(mPointMap.get(i).toString(), xPoints[i] - 20,
                        pointY + textInterval, pointTextPaint);
            }
        }
    }

    /* 设置X轴数值 */
    public void setXItem(List<String> xItem) {
        mXAxis = xItem;
    }

    /* 给坐标点map集合赋值 */
    public void setData(HashMap<Integer, Integer> data) {
        mPointMap = data;
        invalidate();
    }

    public void setMaxY(int maxY) {
        this.maxY = maxY;
    }

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

推荐阅读更多精彩内容