Android自定义折线图

来源:互联网 发布:游戏美工原画招聘 编辑:程序博客网 时间:2024/06/05 15:03

前言

折线图在比较数据、天气等方面的时候会用到,网上也不乏大佬将其封装成完整控件。自己也简单写写看一下效果。先上效果图,毕竟无图何以言diao。

嗯,效果就这样,下面将介绍其从无到有的过程!!

重写View的onMeasure方法

在自定义控件的时候,控件的宽高(最终的测量尺寸)由控件本身和其父容器共同决定的,既然是共同决定的我们得知道父容器的“意愿”对吧!

父容器的“意愿”也就三种,在MeasureSpec类中的三个常量表明了其三种意愿:

1、UNSPECIFIED:父容器对控件的大小不在意,你想多大都可以;

2、AT_MOST:父容器给控件的大小设置了一个最大值,表示你大小的取值最好在这个范围内;

3、EXACTLY: 父容器已经给控件计算出了显示的大小,控件就显示这么大得了;

onMeasure方法具体的代码:

    int resultWidth;    //获取父容器的意愿    int widthMode = MeasureSpec.getMode(widthMeasureSpec);    int sizeWidth = MeasureSpec.getSize(widthMeasureSpec);    if (widthMode == MeasureSpec.EXACTLY) {        resultWidth = sizeWidth;    } else {        if (widthMode == MeasureSpec.AT_MOST) {            resultWidth = Math.min(screenWidth, sizeWidth);        } else {            resultWidth = screenWidth;        }    }    int resultHeight;    int heightMode = MeasureSpec.getMode(heightMeasureSpec);    int sizeHeight = MeasureSpec.getSize(heightMeasureSpec);    if (heightMode == MeasureSpec.EXACTLY) {        resultHeight = sizeHeight;    } else {        if (heightMode == MeasureSpec.AT_MOST) {            resultHeight = Math.min(screenHeight, sizeHeight);        } else {            resultHeight = screenHeight;        }    }    //设置测量出的宽高    setMeasuredDimension(resultWidth, resultHeight);

因为折线图是一个显示出来较占位置的控件,所以在父容器对大小无要求的时候,就以屏幕的大小作为宽高。获取屏幕的宽高:

DisplayMetrics metrics = context.getResources().getDisplayMetrics();    screenWidth = metrics.widthPixels;    screenHeight = metrics.heightPixels;

现在展现在你面前的就是一张“白纸”,想怎么画全看意愿了,哈

理一下画图的顺序

drawTextY(Canvas canvas) //绘制y轴上需要显示的文字
drawTextX(Canvas canvas) //绘制x轴上的文字
drawCoordinate(Canvas canvas) //绘制坐标轴
drawBrokenLine(Canvas canvas) //绘制折线、顶点及顶点文字
差不多就是这个从上到下的顺序!
看到这儿估计有人会说:你特么逗我么,绘制折线图不先画坐标轴?
道友别急,请听我狡辩:绘制折线图有一个关键点是确定点的坐标(特么不是废话么),之所以将坐标轴放到文字之后绘制是因为y上绘制文字的宽度和x轴上文字的高度会对坐标轴的定位有一定影响。因为这两个数据是未知的,所以我们得先确定下来才好进行下一步操作!

drawTextY(Canvas canvas)

先上代码

//y轴文字的最大宽度private int maxTextWidth;//y轴每一刻度的高度private int singleHeight;//文字的高度private int textHeight;//y轴文字listprivate List<String> textY;/** * draw Y轴上的文字 * * @param canvas */private void drawTextY(Canvas canvas) {    if (textY == null) return;    // 文字的高度:mTextPaint.descent() - mTextPaint.ascent()    textHeight = (int) (mTextPaint.descent() - mTextPaint.ascent());    singleHeight = (getHeight() - paddingBottom - paddingTop - textHeight - spacing) / (textY.size() - 1);    int xPos = paddingLeft;    int yPos;    for (int i = 0; i < textY.size(); i++) {        //找出文字所需的最大宽度        if (mTextPaint.measureText(textY.get(textY.size() - i - 1)) > maxTextWidth) {            maxTextWidth = (int) mTextPaint.measureText(textY.get(textY.size() - i - 1));        }        yPos = paddingTop + singleHeight * i + textHeight / 2;        canvas.drawText(textY.get(textY.size() - i - 1), xPos, yPos, mTextPaint);    }}

变量注释应该还算详细,抽两个简单介绍一下吧
textHeight:文字的高度获取,mTextPaint.descent() - mTextPaint.ascent()表达式前后的顺序不要反了,不然得到是一个负数(别问我是怎么知道的)。至于为什么是这样获取,问神奇的www.google.cn吧。
singleHeight:每一刻度的高度,通过控件的高度减去上下边距、文字高度、文字与坐标轴的间距(固定值)除以份数得到,聪明的你一定了然于心了!

drawTextX(Canvas canvas)

//x轴每一刻度的宽度private int singleWidth;//x轴文字listprivate List<String> textX;/** * draw X轴上的文字 * * @param canvas */private void drawTextX(Canvas canvas) {    if (textX == null) return;    singleWidth = (getWidth() - paddingLeft - paddingRight - spacing - maxTextWidth) / (textX.size() - 1);    int xPos;    int yPos = getHeight() - paddingBottom;    for (int i = 0; i < textX.size(); i++) {        xPos = paddingLeft + maxTextWidth + spacing + singleWidth * i - (int) mTextPaint.measureText(textX.get(i)) / 2;        canvas.drawText(textX.get(i), xPos, yPos, mTextPaint);    }}

singleWidth:每一刻度的高度,得到方式与singleHeight差不多,不过减去的值由文字(textHeight)的高度变为文字的宽度(maxTextWidth)
然后对应点上绘制对应的文字!!

drawCoordinate(Canvas canvas)绘制坐标轴

/** * 画坐标轴及点 * * @param canvas 画布 */int spacing = 6;//文字与坐标轴的间距private void drawCoordinate(Canvas canvas) {    //移到画线的起始点(左上角),Y轴顶点    mPath.moveTo(paddingLeft + maxTextWidth + spacing, paddingTop / 2);    //然后移到坐标轴原点    mPath.lineTo(paddingLeft + maxTextWidth + spacing, getHeight() - paddingBottom - textHeight - spacing);    //最后移到X轴顶点    mPath.lineTo(getWidth() - paddingRight / 2, getHeight() - paddingBottom - textHeight - spacing);    //绘制坐标轴    canvas.drawPath(mPath, mPaint);    if (textY == null) {        return;    }    int x0 = paddingLeft + maxTextWidth + spacing;    int y0 = getHeight() - paddingBottom - textHeight - spacing;    //画y轴坐标点    for (int i = 1; i < textY.size(); i++) {        canvas.drawLine(x0, y0 - singleHeight * i, x0 + dp2px(context, 4), y0 - singleHeight * i, mPaint);    }    if (textX == null) {        return;    }    //画x轴坐标点    for (int i = 1; i < textX.size(); i++) {        canvas.drawLine(x0 + singleWidth * i, y0, x0 + singleWidth * i, y0 - dp2px(context, 4), mPaint);    }}

使用Path绘制的坐标轴,一个点确定线的工具类;
然后绘制坐标点,根据singleWidth和singleHeight确定其位置。

drawBrokenLine(Canvas canvas) 绘制折线

/** * 画折线、圆点、顶点文字 * * @param canvas */private void drawBrokenLine(Canvas canvas) {    //确定坐标轴原点的xy值,以此作为依据绘图    int startX = paddingLeft + maxTextWidth + spacing;    int startY = getHeight() - paddingBottom - textHeight - spacing;    //开起循环确定折线经过的坐标点,然后采用Path绘制    for (int i = 0; i < brokenData.length; i++) {        if (i == 0) {            if (brokenData[i] > maxY) {                mBrokenPath.moveTo(startX, startY - singleHeight * (maxY - minY) / singleY);            } else {                mBrokenPath.moveTo(startX, startY - singleHeight * (brokenData[i] - minY) / singleY);            }        } else {            if (brokenData[i] > maxY) {                mBrokenPath.lineTo(startX + singleWidth * i, startY - singleHeight * (maxY - minY) / singleY);            } else {                mBrokenPath.lineTo(startX + singleWidth * i, startY - singleHeight * (brokenData[i] - minY) / singleY);            }        }    }    canvas.drawPath(mBrokenPath, mBrokenLinePaint);    //在画线之后调用,不然会存在线覆盖点的问题(看着怪)    for (int i = 0; i < brokenData.length; i++) {        canvas.drawCircle(startX + singleWidth * i, startY - singleHeight * (brokenData[i] - minY) / singleY, circleRadius, mCirclePaint);        canvas.drawText(String.valueOf(brokenData[i]), startX + singleWidth * i + spacing, startY - singleHeight * (brokenData[i] - minY) / singleY - spacing, mTextVerticesPaint);    }    invalidate();}

到此我们得绘制就差不多完成了。
然后定制控件的所需的属性值:

//线的颜色private int linePaintColor;//折线的颜色private int brokenLinePaintColor;//字的颜色private int textPaintColor;//字的大小private int textPaintSize;//顶点圆点的颜色private int circlePaintColor;//圆点半径private float circleRadius;//顶点文字的颜色private int textVerticesPaintColor;//顶点文字的大小private int textVerticesPaintSize;//线宽private float lineWidth;

最后暴露出设置数据的公共方法:
setTextY(…)//设置Y轴显示的数据
setTextX(…)//设置X轴显示的数据
setBrokenData(…)//设置折线顶点的值
到此差不多完成,然后再修一下边角就大功告成了,给自己点个赞!哈

完整代码

public class BrokenLineView extends View {private Context context;public BrokenLineView(Context context) {    this(context, null);}public BrokenLineView(Context context, @Nullable AttributeSet attrs) {    this(context, attrs, 0);}public BrokenLineView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {    super(context, attrs, defStyleAttr);    obtainAttribute(context, attrs);    init(context);    this.context = context;}//画线的笔private Paint mPaint;//画折线的笔private Paint mBrokenLinePaint;//写坐标轴上文字的笔private TextPaint mTextPaint;//顶点文字的笔private TextPaint mTextVerticesPaint;//画圆点的笔private Paint mCirclePaint;//坐标的pathprivate Path mPath;//折线path,与上面用同一个会存在颜色互串的问题private Path mBrokenPath;//屏幕宽度private int screenWidth;//屏幕高度private int screenHeight;//padding系列private int paddingTop;private int paddingBottom;private int paddingRight;private int paddingLeft;private void init(Context context) {    mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);//抗锯齿    mPaint.setColor(linePaintColor);    mPaint.setStyle(Paint.Style.STROKE);    mPaint.setStrokeWidth(lineWidth);    mBrokenLinePaint = new Paint(Paint.ANTI_ALIAS_FLAG);    mBrokenLinePaint.setColor(brokenLinePaintColor);    mBrokenLinePaint.setStyle(Paint.Style.STROKE);    mBrokenLinePaint.setStrokeWidth(lineWidth);    mTextPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);    mTextPaint.setColor(textPaintColor);    mTextPaint.setTextSize(textPaintSize);    mTextVerticesPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);    mTextVerticesPaint.setColor(textVerticesPaintColor);    mTextVerticesPaint.setTextSize(textVerticesPaintSize);    mCirclePaint = new Paint(Paint.ANTI_ALIAS_FLAG);    mCirclePaint.setColor(circlePaintColor);    mPath = new Path();    mBrokenPath = new Path();    //获取屏幕的宽高    DisplayMetrics metrics = context.getResources().getDisplayMetrics();    screenWidth = metrics.widthPixels;    screenHeight = metrics.heightPixels;    paddingTop = Math.max(dp2px(context, 16), getPaddingTop());    paddingBottom = Math.max(dp2px(context, 12), getPaddingBottom());    paddingLeft = Math.max(dp2px(context, 16), getPaddingLeft());    paddingRight = Math.max(dp2px(context, 24), getPaddingRight());}/** * 得到设置的属性 *///线的颜色private int linePaintColor;//折线的颜色private int brokenLinePaintColor;//字的颜色private int textPaintColor;//字的大小private int textPaintSize;//顶点圆点的颜色private int circlePaintColor;//圆点半径private float circleRadius;//顶点文字的颜色private int textVerticesPaintColor;//顶点文字的大小private int textVerticesPaintSize;//线宽private float lineWidth;private void obtainAttribute(Context context, AttributeSet attrs) {    TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.BrokenLineView);    try {        linePaintColor = typedArray.getColor(R.styleable.BrokenLineView_linePaintColor, Color.BLUE);        brokenLinePaintColor = typedArray.getColor(R.styleable.BrokenLineView_brokenLinePaintColor, Color.BLACK);        textPaintColor = typedArray.getColor(R.styleable.BrokenLineView_textPaintColor, Color.BLACK);        textPaintSize = typedArray.getDimensionPixelSize(R.styleable.BrokenLineView_textPaintSize, sp2px(context, 10));        circlePaintColor = typedArray.getColor(R.styleable.BrokenLineView_circlePaintColor, Color.BLACK);        circleRadius = typedArray.getDimensionPixelSize(R.styleable.BrokenLineView_circleRadius, dp2px(context, 1));        textVerticesPaintColor = typedArray.getColor(R.styleable.BrokenLineView_textVerticesPaintColor, Color.BLACK);        textVerticesPaintSize = typedArray.getDimensionPixelSize(R.styleable.BrokenLineView_textVerticesPaintSize, sp2px(context, 10));        lineWidth = typedArray.getDimensionPixelSize(R.styleable.BrokenLineView_lineWidth, dp2px(context, 1));    } finally {        typedArray.recycle();    }}@Overrideprotected void onDraw(Canvas canvas) {    super.onDraw(canvas);    drawTextY(canvas);    drawTextX(canvas);    drawCoordinate(canvas);    drawBrokenLine(canvas);}//y轴文字的最大宽度private int maxTextWidth;//y轴每一刻度的高度private int singleHeight;//文字的高度private int textHeight;//y轴文字listprivate List<String> textY;/** * draw Y轴上的文字 * * @param canvas */private void drawTextY(Canvas canvas) {    if (textY == null) return;    // 文字的高度:mTextPaint.descent() - mTextPaint.ascent()    textHeight = (int) (mTextPaint.descent() - mTextPaint.ascent());    singleHeight = (getHeight() - paddingBottom - paddingTop - textHeight - spacing) / (textY.size() - 1);    Log.i("tag", "height: " + getHeight() + "  singleHeight: " + singleHeight            + "  bottom: " + paddingBottom + " top: " + paddingTop + "  textHeight: " + textHeight);    int xPos = paddingLeft;    int yPos;    for (int i = 0; i < textY.size(); i++) {        //找出文字所需的最大宽度        if (mTextPaint.measureText(textY.get(textY.size() - i - 1)) > maxTextWidth) {            maxTextWidth = (int) mTextPaint.measureText(textY.get(textY.size() - i - 1));        }        yPos = paddingTop + singleHeight * i + textHeight / 2;        canvas.drawText(textY.get(textY.size() - i - 1), xPos, yPos, mTextPaint);    }}/** * 设置y轴显示的数值 * * @param minY   y轴最小值 * @param maxY   y轴最大值 * @param number 坐标轴分为几部分 * @param unit   数字后面所跟的单位 *///每一部分y轴的取值private int singleY;//y轴最大值private int maxY;//y轴最小值private int minY;public void setTextY(int minY, int maxY, int number, String unit) {    if (maxY - minY <= 0 || number < 1) {        return;    }    this.maxY = maxY;    this.minY = minY;    textY = new ArrayList<>();    singleY = (maxY - minY) / number;    for (int i = 0; i < number + 1; i++) {        textY.add(String.valueOf(i * singleY + minY) + unit);    }    invalidate();}//x轴每一刻度的宽度private int singleWidth;//x轴文字listprivate List<String> textX;/** * draw X轴上的文字 * * @param canvas */private void drawTextX(Canvas canvas) {    if (textX == null) return;    singleWidth = (getWidth() - paddingLeft - paddingRight - spacing - maxTextWidth) / (textX.size() - 1);    int xPos;    int yPos = getHeight() - paddingBottom;    Log.i("tag", "canvasHeight: " + canvas.getHeight() + "  height: " + getHeight());    Log.i("tag", "textHeight: " + textHeight);    for (int i = 0; i < textX.size(); i++) {        xPos = paddingLeft + maxTextWidth + spacing + singleWidth * i - (int) mTextPaint.measureText(textX.get(i)) / 2;        canvas.drawText(textX.get(i), xPos, yPos, mTextPaint);    }}/** * 设置x轴显示的文字 * * @param textX x轴文字集合 */public void setTextX(List<String> textX) {    if (textX == null) return;    this.textX = textX;    invalidate();}/** * 画坐标轴 * * @param canvas 画布 */int spacing = 6;//文字与坐标轴的间距private void drawCoordinate(Canvas canvas) {    //移到画线的起始点(左上角)    mPath.moveTo(paddingLeft + maxTextWidth + spacing, paddingTop / 2);    mPath.lineTo(paddingLeft + maxTextWidth + spacing, getHeight() - paddingBottom - textHeight - spacing);    mPath.lineTo(getWidth() - paddingRight / 2, getHeight() - paddingBottom - textHeight - spacing);    canvas.drawPath(mPath, mPaint);    if (textY == null) {        return;    }    int x0 = paddingLeft + maxTextWidth + spacing;    int y0 = getHeight() - paddingBottom - textHeight - spacing;    //画y轴坐标点    for (int i = 1; i < textY.size(); i++) {        canvas.drawLine(x0, y0 - singleHeight * i, x0 + dp2px(context, 4), y0 - singleHeight * i, mPaint);    }    if (textX == null) {        return;    }    //画x轴坐标点    for (int i = 1; i < textX.size(); i++) {        canvas.drawLine(x0 + singleWidth * i, y0, x0 + singleWidth * i, y0 - dp2px(context, 4), mPaint);    }}/** * 画折线、圆点、顶点文字 * * @param canvas */private void drawBrokenLine(Canvas canvas) {    int startX = paddingLeft + maxTextWidth + spacing;    int startY = getHeight() - paddingBottom - textHeight - spacing;    Log.i("tag", "height: " + getHeight() + "  singleHeight: " + singleHeight            + "  bottom: " + paddingBottom + " top: " + paddingTop + "  textHeight: " + textHeight);    for (int i = 0; i < brokenData.length; i++) {        if (i == 0) {            if (brokenData[i] > maxY) {                mBrokenPath.moveTo(startX, startY - singleHeight * (maxY - minY) / singleY);            } else {                mBrokenPath.moveTo(startX, startY - singleHeight * (brokenData[i] - minY) / singleY);            }        } else {            if (brokenData[i] > maxY) {                mBrokenPath.lineTo(startX + singleWidth * i, startY - singleHeight * (maxY - minY) / singleY);            } else {                mBrokenPath.lineTo(startX + singleWidth * i, startY - singleHeight * (brokenData[i] - minY) / singleY);            }        }    }    canvas.drawPath(mBrokenPath, mBrokenLinePaint);    //在画线之后调用,不然会存在线覆盖点的问题(看着怪)    for (int i = 0; i < brokenData.length; i++) {        canvas.drawCircle(startX + singleWidth * i, startY - singleHeight * (brokenData[i] - minY) / singleY, circleRadius, mCirclePaint);        canvas.drawText(String.valueOf(brokenData[i]), startX + singleWidth * i + spacing, startY - singleHeight * (brokenData[i] - minY) / singleY - spacing, mTextVerticesPaint);    }    invalidate();}//折线图的值private float[] brokenData;/** * 设置折线图顶点的值 * * @param data 数值数组 */public void setBrokenData(float[] data) {    if (data == null) return;    this.brokenData = data;    invalidate();}@Overrideprotected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {    super.onMeasure(widthMeasureSpec, heightMeasureSpec);    int resultWidth;    int widthMode = MeasureSpec.getMode(widthMeasureSpec);    int sizeWidth = MeasureSpec.getSize(widthMeasureSpec);    if (widthMode == MeasureSpec.EXACTLY) {        resultWidth = sizeWidth;    } else {        if (widthMode == MeasureSpec.AT_MOST) {            resultWidth = Math.min(screenWidth, sizeWidth);        } else {            resultWidth = screenWidth;        }    }    int resultHeight;    int heightMode = MeasureSpec.getMode(heightMeasureSpec);    int sizeHeight = MeasureSpec.getSize(heightMeasureSpec);    if (heightMode == MeasureSpec.EXACTLY) {        resultHeight = sizeHeight;    } else {        if (heightMode == MeasureSpec.AT_MOST) {            resultHeight = Math.min(screenHeight, sizeHeight);        } else {            resultHeight = screenHeight;        }    }    setMeasuredDimension(resultWidth, resultHeight);}/** * dp转px * * @param context * @return */private int dp2px(Context context, float dpVal) {    return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,            dpVal, context.getResources().getDisplayMetrics());}/** * sp转px * * @param context * @return */private int sp2px(Context context, float spVal) {    return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP,            spVal, context.getResources().getDisplayMetrics());}}

属性定义文件,values下attrs.xml

<resources><declare-styleable name="BrokenLineView">    <attr name="linePaintColor" format="color" />    <attr name="textPaintColor" format="color" />    <attr name="textPaintSize" format="dimension" />    <attr name="circlePaintColor" format="color" />    <attr name="circleRadius" format="dimension" />    <attr name="textVerticesPaintColor" format="color" />    <attr name="textVerticesPaintSize" format="dimension" />    <attr name="brokenLinePaintColor" format="color" />    <attr name="lineWidth" format="dimension" /></declare-styleable></resources>

有兴趣的可以看一下
Kotlin编写RecyclerView的Adapter
ARouter使用时build.gradle的配置

原创粉丝点击