Android自定义View之扇形饼状图

前言:继上次写了自定义圆形进度条后,今天给大家带来自定义扇形饼状图。先上效果图:
Android自定义View之扇形饼状图_第1张图片
是不是很炫?看上去还有点立体感。下面带大家一起来瞧一瞧吧。

一、定义成员变量,重写构造方法

看着这个效果图,我们可以想象下接下来暂时会需要用到以下属性:

    /**
     * 存放事物的品种与其对应的数量
     */
    private Map kindsMap = new LinkedHashMap();
    /**
     *  存放颜色
     */
    private ArrayList colors = new ArrayList<>();

    private Paint mPaint;//饼状画笔
    private Paint mTextPaint; // 文字画笔
    private static final int DEFAULT_RADIUS = 200;
    private int mRadius = DEFAULT_RADIUS; //外圆的半径
    private String centerTitle; //中间标题

然后重写父类的构造方法,初始化画笔:

 public PieChatView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        mPaint = new Paint();
        mTextPaint = new Paint();

        mPaint.setAntiAlias(true);
        mPaint.setStyle(Paint.Style.FILL);

        mTextPaint.setColor(Color.BLACK);
        mTextPaint.setAntiAlias(true);
        mTextPaint.setStyle(Paint.Style.STROKE);
        mTextPaint.setTextAlign(Paint.Align.CENTER);
    }

    public PieChatView(Context context) {
        this(context, null, 0);

    }

    public PieChatView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

一般都是一个参数和两个参数的全部调用第三个参数的。我三个参数的构造方法中没有去从xml文件中去获取属性了。我完全就用代码实现了。也可以将一些属性放在attrs.xml文件中,然后去获取。自行选择吧。

二、onMeasure()

这个方法,只要你以前写过自定义View,基本上就是一样的套路:

@Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int wideSize = MeasureSpec.getSize(widthMeasureSpec);
        int wideMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int width, height;
        if (wideMode == MeasureSpec.EXACTLY) { //精确值 或matchParent
            width = wideSize;
        } else {
            width = mRadius * 2 + getPaddingLeft() + getPaddingRight();
            if (wideMode == MeasureSpec.AT_MOST) {
                width = Math.min(width, wideSize);
            }

        }

        if (heightMode == MeasureSpec.EXACTLY) { //精确值 或matchParent
            height = heightSize;
        } else {
            height = mRadius * 2 + getPaddingTop() + getPaddingBottom();
            if (heightMode == MeasureSpec.AT_MOST) {
                height = Math.min(height, heightSize);
            }

        }
        setMeasuredDimension(width, height);
        mRadius = (int) (Math.min(width - getPaddingLeft() - getPaddingRight(),
                height - getPaddingTop() - getPaddingBottom()) * 1.0f / 2);

    }

就是获得系统测量好的宽高,和模式后分三种模式去讨论。最终通过setMeasuredimension()确定宽高的值。宽高确定好了,那就可以确定下整个饼状图的半径 mRadius了。取宽高中的较小的那个。

三、onDraw();

我们需要画一个一个的扇形,还有将扇形从零到360的动画效果,还有扇形中的文字,中间的文字,还有实现立体感的效果。

1.画扇形。

画一个扇形还是蛮容易的:通过画布调用drawArc()方法话一个60度的扇形:
mPaint.setStyle(Paint.Style.FILL);
mTextPaint.setColor(Color.RED);
RectF oval = new RectF(-mRadius, -mRadius, mRadius, mRadius);
mCanvas.drawArc(oval, 0, 60, true, mPaint);

前面设置下画笔的颜色,style为实心,就这几行代码的事情。第一个参数:是一个正方形,表明在这个区域内画扇形。第二个参数:从哪个角度开始画,第三个参数:就是画的角度度数,而不是画到哪个角度去。第四个参数:是否以正方形中心为圆心。我这样一说,你可能就会有点懵逼了。你自己改为false,看效果,就理解我说的了。效果如下:
Android自定义View之扇形饼状图_第2张图片
目前画了一个扇形,如此多的扇形需要用户去设置数据,来确定每个扇形的角度.

    public ArrayList getColors() {
        return colors;
    }

    public void setColors(ArrayList colors) {
        this.colors = colors;
    }
     public void setDataMap(LinkedHashMap map) {
        this.kindsMap = map;
    }
     public String getCenterTitle() {
        return centerTitle;
    }

    public void setCenterTitle(String centerTitle) {
        this.centerTitle = centerTitle;
    }

然后通过遍历,一 一画出这些扇形:

 @Override
    protected void onDraw(Canvas mCanvas) {
        super.onDraw(mCanvas);
        mCanvas.translate((getWidth() + getPaddingLeft() - getPaddingRight()) / 2, (getHeight() + getPaddingTop() - getPaddingBottom()) / 2);

        paintPie(mCanvas);

    }
private void paintPie(final Canvas mCanvas) {
     if (kindsMap != null) {
            Set> entrySet = kindsMap.entrySet();
            Iterator> iterator = entrySet.iterator();
            int i = 0;
            float currentAngle = 0.0f;
            while (iterator.hasNext()) {
                Map.Entry entry = iterator.next();
                int num = entry.getValue();
                float needDrawAngle = num * 1.0f / sum * 360;
                mPaint.setColor(colors.get(i));
                mCanvas.drawArc(oval, currentAngle, needDrawAngle - 1, true, mPaint);
                currentAngle = currentAngle + needDrawAngle;
                i++;
            }
        }  
        }

记得,要先将画笔移到画布中心,currentAngle:当前的角度值,needDrawAngle :需要画多少角度。sum:数据的总个数,为每一个数据相加的和,计算百分比。效果如图:
Android自定义View之扇形饼状图_第3张图片

2、动画效果实现

咋一看离我们的预期效果还差好远。只有画完以后的样子,动画都没有。别急别急,动画马上就来:

 private void initAnimator() {
        ValueAnimator anim = ValueAnimator.ofFloat(0, 360);
        anim.setDuration(10000);
        anim.setInterpolator(new AccelerateDecelerateInterpolator());
        anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {

            @Override
            public void onAnimationUpdate(ValueAnimator valueAnimator) {
                animatedValue = (float) valueAnimator.getAnimatedValue();
                invalidate();
            }
        });
        anim.start();
    }

设置一个动画,在10秒内,随着时间的推移,animatedValue从0到360度发生变化,每一次监听到animatedValue的值得改变,就去刷新View,执行ondraw();这个时候我们来修改下前面的paintPie();

if (Math.min(needDrawAngle-1, animatedValue - currentAngle) >= 0) {
     mPaint.setColor(colors.get(i));
     mCanvas.drawArc(oval, currentAngle, Math.min(needDrawAngle - 1, animatedValue - currentAngle), true, mPaint);
                }

先看效果:
Android自定义View之扇形饼状图_第4张图片

我在这个地方遇到了一个坑----ondraw()方法多次执行时,每一次执行完毕后会抹去上一次画过的图形.也就是说,我没用动画的时候,ondraw()方法就调用了一次,调用了while循环后,把所有的扇形画出来了。用了动画之后,animatedValue的值一改变,就会执行一次while循环。每次while循环都会重头开始画,我们只要保证每次while循环所画的角度正好是animatedValue,就ok了。所以,在needDrawAngle-1小于animatedValue - currentAngle时,就把该部分扇形画出来。在needDrawAngle-1大于animatedValue - currentAngle时,那就只画animatedValue - currentAngle的角度。

之所以减一,是因为扇形之间留一条白白的缝隙。

3.添加文字,中间标题,实现立体感

当某个扇形角度,不够文字的宽度时,我们就会将文字画在圆的外面。所以这个我们得添加一个属性minAngle,角度最小值,添加getter和setter方法,让使用者去设置。当needDrawAngle小于这个角度值时,就画在外面。大于这个值就画在扇形中央。

//画文字
    private void drawText(Canvas mCanvas, float textAngle, String kinds, float needDrawAngle) {
        Rect rect = new Rect();
        mTextPaint.setTextSize(sp2px(15));
        mTextPaint.getTextBounds(kinds, 0, kinds.length(), rect);
        if (textAngle >= 0 && textAngle <= 90) { //画布坐标系第一象限(数学坐标系第四象限)
            if (needDrawAngle < minAngle) { //如果小于某个度数,就把文字画在饼状图外面
                mCanvas.drawText(kinds, (float) (mRadius * 1.2 * Math.cos(Math.toRadians(textAngle))), (float) (mRadius * 1.2 * Math.sin(Math.toRadians(textAngle)))+rect.height()/2, mTextPaint);
            } else {
                mCanvas.drawText(kinds, (float) (mRadius * 0.75 * Math.cos(Math.toRadians(textAngle))), (float) (mRadius * 0.75 * Math.sin(Math.toRadians(textAngle)))+rect.height()/2, mTextPaint);
            }
        } else if (textAngle > 90 && textAngle <= 180) { //画布坐标系第二象限(数学坐标系第三象限)
            if (needDrawAngle < minAngle) {
                mCanvas.drawText(kinds, (float) (-mRadius * 1.2 * Math.cos(Math.toRadians(180 - textAngle))), (float) (mRadius * 1.2 * Math.sin(Math.toRadians(180 - textAngle)))+rect.height()/2, mTextPaint);
            } else {
                mCanvas.drawText(kinds, (float) (-mRadius * 0.75 * Math.cos(Math.toRadians(180 - textAngle))), (float) (mRadius * 0.75 * Math.sin(Math.toRadians(180 - textAngle)))+rect.height()/2, mTextPaint);
            }
        } else if (textAngle > 180 && textAngle <= 270) { //画布坐标系第三象限(数学坐标系第二象限)
            if (needDrawAngle < minAngle) {
                mCanvas.drawText(kinds, (float) (-mRadius * 1.2 * Math.cos(Math.toRadians(textAngle - 180))), (float) (-mRadius * 1.2 * Math.sin(Math.toRadians(textAngle - 180)))+rect.height()/2, mTextPaint);
            } else {
                mCanvas.drawText(kinds, (float) (-mRadius * 0.75 * Math.cos(Math.toRadians(textAngle - 180))), (float) (-mRadius * 0.75 * Math.sin(Math.toRadians(textAngle - 180)))+rect.height()/2, mTextPaint);
            }
        } else { //画布坐标系第四象限(数学坐标系第一象限)
            if (needDrawAngle < minAngle) {
                mCanvas.drawText(kinds, (float) (mRadius * 1.2 * Math.cos(Math.toRadians(360 - textAngle))), (float) (-mRadius * 1.2 * Math.sin(Math.toRadians(360 - textAngle)))+rect.height()/2, mTextPaint);
            } else {
                mCanvas.drawText(kinds, (float) (mRadius * 0.75 * Math.cos(Math.toRadians(360 - textAngle))), (float) (-mRadius * 0.75 * Math.sin(Math.toRadians(360 - textAngle)))+rect.height()/2, mTextPaint);
            }
        }

    }

首先,画文字,我们要知道文字的中心点坐标。所以怎么来求呢?看图:
Android自定义View之扇形饼状图_第5张图片
通过这幅图,大家应该知道了吧。唯一一点要说的就是画图的坐标系和数学中的坐标系不一样。画的时候还要分四个象限的情况去讨论。有的人说,为什么不是在1/2r处,而是在0.75r处呢?因为我们中心还要画title的呀,对吧。画在1/2处,不就覆盖了嘛!
然后在中心画文字咯。文字很容易。先画个白色背景的圆半径为外圆半径的一半,这样就覆盖了扇形中间的一部分。在内圆上再画字。就OK了。
那立体效果怎么实现呢?
也很简单,可以仔细观察,那部分好像是透明的。隐约能看见前面画的扇形。所以我们在画内园之前先画个比内园稍微大一点的透明圆。设置画笔的透明度就搞定。上代码:

            while (iterator.hasNext()) {
                Map.Entry entry = iterator.next();
                String kinds = entry.getKey();
                int num = entry.getValue();
                float needDrawAngle = num * 1.0f / sum * 360;
                String drawAngle = dff.format(needDrawAngle / 360 * 100);
                kinds = kinds + "," + drawAngle + "%";
                float textAngle = needDrawAngle / 2 + currentAngle;
                if (Math.min(needDrawAngle, animatedValue - currentAngle) >= 0) {
                    mPaint.setColor(colors.get(i));
                    mCanvas.drawArc(oval, currentAngle, Math.min(needDrawAngle - 1, animatedValue - currentAngle), true, mPaint);

                    mPaint.setColor(Color.WHITE);
                    mPaint.setAlpha(10);
                    mCanvas.drawCircle(0, 0, mRadius / 2 + dp2px(10), mPaint);
                    mPaint.setAlpha(255);
                    mCanvas.drawCircle(0, 0, mRadius / 2, mPaint);
                    drawCenterText(mCanvas, centerTitle, 0, 0, mTextPaint);
                    drawText(mCanvas, textAngle, kinds, needDrawAngle);
                }

                currentAngle = currentAngle + needDrawAngle;
                i++;
            }

该画的画完了。接下来就是一些优化和修改了。

四、优化

1.添加dp,sp,px转化工具

/**
     * dp 2 px
     *
     * @param dpVal
     */
    protected int dp2px(int dpVal) {
        return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,
                dpVal, getResources().getDisplayMetrics());
    }

    /**
     * sp 2 px
     *
     * @param spVal
     * @return
     */
    protected int sp2px(int spVal) {
        return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP,
                spVal, getResources().getDisplayMetrics());

    }

因为图中有很多地方接触到文字,如果设置文字的时候用的是px,那么在不同的机器上,显示的文字大小将会差距很大。最好也提供一些方法让使用者去设置这些字体的大小。

2.让使用者去开启动画

因为数据是使用者设置上去的,什么时候开启动画,还得提供一个公开方法:

 public void startDraw() {
        if (kindsMap != null && colors != null && centerTitle != null) {
            initAnimator();
        }
    }

3.设置数据优化

可以知道,我用了两个容器来装数据。有没有感觉到浪费资源?其实我们完全可以只用一个ArrayList即可。对吧。将所有数据定义为一个实体的bean类。说到这,又遇到一个坑,我最开始使用hashMap去存数据的,优越hashMap存放数据是无序的,所以每次画出来的扇形结构不一样。你也许又会问我,怎么颜色总是飘忽不定啊?因为我是用了个for循环随机生成的颜色,哈哈。

 @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        PieChatView pieChatView = (PieChatView) findViewById(R.id.pie);
        kindsMap.put("苹果", 10);
        kindsMap.put("梨子", 30);
        kindsMap.put("香蕉", 10);
        kindsMap.put("葡萄", 30);
        kindsMap.put("哈密瓜", 10);
        kindsMap.put("猕猴桃",30);
        kindsMap.put("草莓", 10);
        kindsMap.put("橙子", 30);
        kindsMap.put("火龙果", 10);
        kindsMap.put("椰子", 20);
        for (int i = 1; i <= 40; i++){
            int r= (new Random().nextInt(100)+10)*i;
            int g= (new Random().nextInt(100)+10)*3*i;
            int b= (new Random().nextInt(100)+10)*2*i;
            int color = Color.rgb(r,g,b);
            if(Math.abs(r-g)>10&&Math.abs(r-b)>10&&Math.abs(b-g)>10){
                colors.add(color);
            }
        }
        pieChatView.setCenterTitle("水果大拼盘");
        pieChatView.setDataMap(kindsMap);
        pieChatView.setColors(colors);
        pieChatView.setMinAngle(50);
        pieChatView.startDraw();

到这里,文章就结束了。最后附上代码的github地址:
https://github.com/Demidong/ClockView
我写的博客还不是很多,欢迎大家参与讨论,留言,指正我的不足。

你可能感兴趣的:(Android)