自定义图表,附带星星闪烁动画

最近公司项目需要用到图表,拿到UI设计时,感觉也不是很难。用著名的MPAndroidChart库改改就可以了,可是领导说图表中的点要一闪一闪,还要先亮前一个,再亮下一个。是不是没听懂,我当时也是一脸懵逼。

先看公司给的UI设计:


9CCF48D3-14AC-4B00-BDE1-A69A8DC58DC6.png

由于MPAndroidChart没有那个动画效果,于是我就决定自定义一个这样的图表。先看看我实现的DEMO效果,在最后附上一张放入项目中的截图。

linechart.gif

DEMO代码地址:https://github.com/jinxiyang/LineChart

自定义控件

自定义控件,有好几种方式。本次我们就继承View实现一个这样的图表。让我们回顾一下,继承View自定义控件的步骤:

  1. 自定义View的属性
  2. 在View的构造方法中获取我们定义的属性值
  3. 重写onMeasure方法,测量View尺寸
  4. 重写onDraw方法,绘制控件
  5. 重写onTouchEvent方法,处理点击事件

下面开始自定义我们的图表。

分析图表所需要的属性

绘制图表时,我们要知道图表各个元素的样式,分析一下总共有:

  1. 图表中文字的颜色、大小
  2. 各种线条的颜色、宽度
  3. 阴影的颜色、透明度
  4. 点的颜色、半径
  5. 标记maker的字体颜色、大小

……

    //默认的动画脉冲间隔
    private static final long DEFAULT_INTERVAL_TIME = 20;
    //默认x轴最大显示几项
    private static final int DEFAULT_X_MAX_ITEM_NUM = 10;
    //默认y轴最大显示几格
    private static final int DEFAULT_Y_MAX_ITEM_NUM = 5;
    //默认点的最大半径,dp
    private static final int DEFAULT_MAX_POINT_RADIUS = 6;
    //坐标轴文字的颜色
    private int axisTextColor =  Color.rgb(205, 137, 118);
    //标轴文字的大小,sp
    private int axisTextSize = 14;
    //文字和x坐标轴之间的间距,dp
    private int yAxisGap = 3;
    //文字和y坐标轴之间的间距,dp
    private int xAxisGap = 3;
    //x坐标轴的颜色
    private int xAxisColor = Color.rgb(205, 137, 118);
    //x坐标轴的宽度,dp
    private int xAxisWidth = 2;
    //x坐标轴下面小竖线的高度,dp
    private int xAxisChildLineHeight = 5;
    //虚线的颜色
    private int dashedLineColor = Color.argb(155, 19, 113, 187);
    //虚线的宽度,dp
    private int dashedLineWidth = 1;
    //虚线中每段实线的宽度,dp
    private int dashWidth = 5;
    //虚线中实线间的间隔,dp
    private int dashGap = 3;
    //阴影的颜色
    private int shadowColor = Color.argb(100, 177, 234, 253);
    //点的颜色
    private int pointColor = Color.argb(155, 19, 113, 187);
    //点之间连线的颜色
    private int lineColor = Color.rgb(0, 220, 255);
    //点之间连线的宽度,dp
    private int lineWidth = 2;
    //悬浮maker标记的字体颜色
    private int makerTextColor = Color.rgb(255, 255, 255);
    //悬浮maker标记的字体大小,dp
    private int makerTextSize = 14;
    //悬浮maker标记的背景颜色
    private int makerBackgroundColor = Color.argb(155, 19, 113, 187);
    //悬浮maker标旁边竖线的颜色
    private int makerLineColor = Color.rgb(205, 137, 118);
    //悬浮maker标旁边竖线的宽度,dp
    private int makerLineWidth = 1;
    //悬浮maker的padding,dp
    private int makerPadding = 10;

(⊙o⊙)…,不写图表不知道,原来需要这么多属性,怪不得大名鼎鼎的MPAndroidChart也没有定义属性,那我们也不定义了。(偷了会懒,_)。提供一些setter方法,用的时候设置一下就好了。

    public void setAxisTextColor(int axisTextColor) {
        this.axisTextColor = axisTextColor;
    }
    public void setAxisTextSize(int axisTextSize) {
        this.axisTextSize = axisTextSize;
    }
    public void setyAxisGap(int yAxisGap) {
        this.yAxisGap = yAxisGap;
    }

    ……//省略

这些值在用的时候,我们会把他们从标注的单位sp、dp,转为绘制时的px

    //dp转为px
    public int dpToPx(DisplayMetrics dm, int dp){
        return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, dm);
    }
    //sp转为px
    public int spToPx(DisplayMetrics dm, int sp){
        return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp, dm);
    }

初始化我们的构造函数

    public LineChart(Context context, AttributeSet attrs, int defStyleAttr) {    
        super(context, attrs, defStyleAttr);    
        init();
    }
    private void init() {   
        mPaint = new Paint(); 
        lineHeadPoint = new ChartPoint();    
        mPoints = new ArrayList<>();    
        calculatePoint();
    }

mPaint 绘图的画笔
lineHeadPoint 动画执行时连线的头的坐标
calculatePoint() 根据提供的数据计算点的坐标,待会再讲这个方法

重写onMeasure方法,测量View尺寸

仔细观察UI给的图表,分析知道这个图表的尺寸肯定是给定的,也就是制定了dp尺寸,或者直接设置了match_parent。所以我们就不要测量了。好吧,我又一次偷了懒。

    //不作处理,因为模板可知宽高一定
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    }

那图表的尺寸我们还是需要知道的,这是我们重写onSizeChanged方法

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh); 
        mWidth = w; 
        mHeight = h;
    }

onMeasure中系统帮我们测量尺寸,onSizeChanged在view尺寸改变时会回调这个方法,我们就拿到了宽和高。

重写onDraw方法,绘制控件

绘制图形,Canvas、Paint、Path这三个知识点一点要讲一讲。

Canvas

Canvas画布,代表了“依附”于指定View的画布,它提供了丰富的方法绘制各种图形。

  • drawArc () //绘制弧
  • drawBitmap() //绘制位图
  • drawCircle() //绘制圆
  • drawOval() //绘制椭圆
  • drawLine() //绘制一条直线
  • drawPoint() //绘制一个点
  • drawRect() //绘制矩形
  • drawRoundRect() //绘制圆角矩形
  • drawPath() //沿着指定路径绘制任意图形
  • drawText() //绘制文字
  • drawTextOnPath //沿着指定路径绘制文字
    ……

观察UI给我们样图,这个图表其实就是,先计算坐标,然后相应位置处的点、线、矩形、多边形、文字。

Paint

Paint类主要用于设置绘制的风格,包括画笔颜色、画笔笔触粗细、填充风格等。

  • setARGB/setColor //设置颜色
  • setAlpha //设置透明度
  • setAntiAlias //设置是否抗锯齿
  • setPathEffect //设置绘制路径时的路径效果
  • setShader //设置画笔的填充效果
  • setShadowLayer //设置阴影
  • setStrokeWidth //设置画笔的笔触宽度
  • setStyle //设置Paint的填充风格
  • setTextAlign //设置绘制文本的文字对齐方式
  • setTextSize //设置绘制文本的文字大小
    ……

我们在画布上绘制东西时,有画笔才能绘制东西,通过画笔我们可以设置绘制的颜色、线的宽度、透明度、文字大小等。

Path

Path路径,将N个点连城一条路径,通过Canvas的drawPath(path,paint)方法沿着路径绘制图形,可以是封闭的也可以是不封闭的路径。PathEffect有一个子类DashPathEffect,我们用它设置给path.setPathEffect()绘制虚线。

有了上面的知识,图表就可以拆分为点、线、虚线、文字、折现、多边形等进行绘制,绘制时计算好正确坐标、设置相应的Paint属性,就可以绘制出图表了。

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        if (needCalculatePoint){
            calculatePoint(); //根据实际数据计算相应的坐标值   
        }
        drawXAxis(canvas);//绘制x坐标轴
        drawDashedLines(canvas);//绘制虚线
        drawXAxisLabel(canvas);//绘制x坐标轴上的文字
        drawYAxisLabel(canvas);//绘制y坐标轴上的文字
        if (mPoints.isEmpty()){
            isAniming = false;
            mProgress = 0;
            return;
        }
        drawLine(canvas);//绘制点和点之间的连线
        drawShadow(canvas);//绘制多边形阴影
        drawChartPoints(canvas);//绘制数据点
        if (pointIsSelected){
            drawMakerLine(canvas);//当点击点时,绘制点的垂直标线
            drawMaker(canvas);//当点击点时,绘制点旁边的矩形介绍框maker
        }
        if (isAniming){
            mProgress += intervalProgress;
            if (mProgress >= mPoints.size()) isAniming = false;
            if (onChartAnimatorListener != null){
                onChartAnimatorListener.onAnimFinished();
            }
            postInvalidateDelayed(intervalTime);
        }
    }

我们把图表拆成几部分内容进行分别绘制:x坐标轴、x坐标轴上的文字、y坐标轴上的文字、虚线、点和点之间的连线、多边形阴影、数据点、垂直标线、矩形介绍框maker。这些部分的绘制流程都是相同的:

  • 设置画笔Paint风格
  • 设置计算坐标
  • canvas绘图

抽几个例子来讲,一、绘制x坐标轴

    //绘制x坐标轴
    private void drawXAxis(Canvas canvas) {
        //设置画笔风格
        mPaint.reset();
        mPaint.setColor(xAxisColor);
        mPaint.setAntiAlias(true);
        mPaint.setStyle(Paint.Style.STROKE);
        mPaint.setStrokeWidth(dpToPx(mDm, xAxisWidth));//dp换算成px
        //计算坐标点并绘制x坐标轴直线
        canvas.drawLine(originX, originY, originX + xUnit * xItemNum, originY, mPaint);
        int startX = (int) (originX + 0.5 * xUnit);
        int childLineHeight = dpToPx(mDm, xAxisChildLineHeight);
       for (int i = 0; i < xItemNum; i++){
            canvas.drawLine(startX + i * xUnit, originY, startX + i * xUnit, originY + childLineHeight, mPaint);//绘制x坐标轴向下的小锤线
        }
    }

二、绘制x轴文字

    //绘制x轴坐标文字
    private void drawXAxisLabel(Canvas canvas) {
        //设置画笔风格
        mPaint.reset();
        mPaint.setColor(axisTextColor);
        mPaint.setTextSize(spToPx(mDm, axisTextSize));
        mPaint.setAntiAlias(true);
        Rect textRect = new Rect();
        int childLineHeight = dpToPx(mDm, xAxisChildLineHeight);
        int gap = dpToPx(mDm, xAxisGap);
        int size = xLabels.size();
        for (int i = 0; i < size; i++){
            String label = xLabels.get(i);
            if (TextUtils.isEmpty(label)){
                break;
            }
            //获取文字的宽和高的矩形框
            mPaint.getTextBounds(label, 0, label.length(), textRect);
            //计算文字的左边和底边坐标值
            int x = (int) (originX + (i + 0.5) * xUnit - textRect.width()/2);
            int y = originY + childLineHeight + gap + textRect.height();
            //绘制文字
            canvas.drawText(label, x, y, mPaint);
        }
    }

三、绘制多边形阴影

    //绘制阴影
    private void drawShadow(Canvas canvas) {
        int floor = (int) Math.floor(mProgress);
        if (floor == 0 || mPoints.size() < 2){
            return;
        }
        //设置画笔风格
        mPaint.reset();
        mPaint.setColor(shadowColor);
        mPaint.setStyle(Paint.Style.FILL);
        mPaint.setAntiAlias(true);
        Path path = new Path();
        ChartPoint firstP = mPoints.get(0);
        path.moveTo(firstP.getX(), firstP.getY());
        //连接各个数据点的坐标
        for (int i = 1; i < floor; i++) {
            ChartPoint p = mPoints.get(i);
            path.lineTo(p.getX(), p.getY());
        }
        path.lineTo(lineHeadPoint.getX(), lineHeadPoint.getY());
        path.lineTo(lineHeadPoint.getX(), originY);
        path.lineTo((float) (originX + 0.5 * xUnit), originY);

        //多边形曲线
        path.close();
        //绘制多边形
        canvas.drawPath(path, mPaint);
    }

动画的实现原理

随着时间的变化,移动多边形阴影右边竖线的坐标,根据坐标绘制左侧应该显示数据点连线,绘制右侧点,右侧点的半径大小随着坐标离最近的左侧坐标的距离(这个距离下图x)变化而变化。

123.png

右边闪烁点的绘制,mProgress - ceil就是x

    //绘制闪烁的点
    private void drawFlashPoint(Canvas canvas, int ceil) {
        ChartPoint flashP = mPoints.get(ceil - 1);
        if (isAniming){
            //绘制闪烁点
            //函数y = |cos(pi * (mProgress - ceil) * 5/2)|       动画实现的关键
            double flashParam = Math.abs(Math.cos(Math.PI * (mProgress - Math.floor(mProgress)) * 5 / 2));
            canvas.drawCircle(flashP.getX(), flashP.getY(), (float) (maxPointRadius * flashParam), mPaint);
        }else {
        //绘制最后一个点
        canvas.drawCircle(flashP.getX(), flashP.getY(), dpToPx(mDm, pointRadius), mPaint);
        }
    }

函数y = |cos(pi * (mProgress - ceil) * 5/2)|

7670550A1295E4FD7F0EB80AB24FBAB8.jpeg

重写onTouchEvent方法,处理点击事件

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()){
            //处理手指按下的事件
            case MotionEvent.ACTION_DOWN:
                if (isAniming){
                    return true;
                }
               //检查是否点到了数据点上了
                selectedPointId = findPointIdNearbyLocation(event.getX(), event.getY());
                if (selectedPointId != -1){//如果是,请求重绘界面,绘制maker
                    pointIsSelected = true;
                    invalidate();
                }
                break;
            case MotionEvent.ACTION_MOVE://不处理滑动事件
                break;
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL://当手指抬起或者画出view时,发送延时消息,隐藏maker
                if (pointIsSelected){
                    mHandler.removeMessages(0x123);
                    //清空上一次的消息
                    mHandler.sendEmptyMessageDelayed(0x123, showMakerTime);
                }
                break;
        }
        return true;
    }
    //在所给位置附近找到最近的图表中的点, 范围0-size    -1代表没找到
    private int findPointIdNearbyLocation(float x, float y) {
        if (mPoints.isEmpty() || x < originX || x > originX + xItemNum * xUnit){
            return -1;
        }
        double id = (x - originX) / xUnit - 0.5;
        int floor = (int) Math.floor(id);
        int ceil = (int) Math.ceil(id);
        if (floor >= 0 && floor < mPoints.size()){
            ChartPoint p = mPoints.get(floor);
            double interval = Math.pow(x - p.getX(), 2) + Math.pow(y - p.getY(), 2) - 30 * 30;
            if (interval < 0){
                return floor;
            }
        }
        if (ceil >= 0 && ceil < mPoints.size()){
            ChartPoint p = mPoints.get(ceil);
            double interval = Math.pow(x - p.getX(), 2) + Math.pow(y - p.getY(), 2) - 30 * 30;
            if (interval < 0){
                return ceil;
            }
        }
        return -1;
    }

通过上面5个步骤就可以自定控件了,图表的控件,计算坐标是难点,每个人都已自己的思考方式,可能大家也没读懂,其实这个不要紧,熟悉整个自定义流程就可以了。

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

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,409评论 25 707
  • 一、概述 1. 四线格与基线 小时候,我们在刚开始学习写字母时,用的本子是四线格的,我们必须把字母按照规则写在四线...
    addapp阅读 7,599评论 2 17
  • 一、Android开发初体验 二、Android与MVC设计模式模型对象存储着应用的数据和业务逻辑。模型类通常用来...
    为梦想战斗阅读 874评论 0 3
  • 我走上荆州古城墙, 刺骨的寒风吹来历史的苍凉。 茂盛的古树下, 旅客们的游车铃声回荡, 播送出时间在悠闲中的空旷。...
    曹焕甫阅读 1,027评论 0 2
  • 绿绿青叶板栗果、见证数年风雨雪;五年坚守为一栗、刺向未来心华丽!
    神于天圣于地阅读 187评论 0 0