Android自定义view ---- 饼状图

2,042 阅读7分钟

前言

最近项目中遇到一个需求,需要一个饼状图,显示百分比,点击每一个扇形区域可以切换下面列表的数据。拿到这个需求后首先想到了MPAndroidChart等第三方库,这个库中包含了各种各样的图表,冷静下来一想,整个项目中就这一个地方用到,那么引入这个库必然会增大项目的体积。所以呢,还是自己搞一个算了。、

效果图

先看一下 最终的效果图:

最终效果.png
最终效果.png
设计思路

看了效果图,是不是感觉还不错。 其实实现起来还是挺简单的,先来理清楚思路,思路理清楚了,相信你也可以的。

1.创建类集成View,实现onDraw。这里类名为MyPieChart。
2.将每个扇形封装成一个类,也可以说是对象,这里命名为PieEntry。其中包含三个元素:数值、颜色、是否被选中、起始角度、结束角度(用于点击事件)。
3.创建一个init方法来初始化Paint。并在构造方法中调用init方法
4.在onDraw方法中画图。也就是逐个的画扇形。
  • 计算总值,也就是遍历List<PieEntry>,将每个PieEntry的数值相加。
  • 获取中心点坐标和两个半径,一个是被选中的半径稍微大一点,另一个是未选中的坐标稍微小一点。
  • 再次遍历List<PieEntry> 画出每个扇形,在这个循环体内,我们需要计算出当前扇形的角度,然后累加的起始角度,作为下一个扇形的起始角度。并在这个循环体内画出扇形。然后画出外围显示的百分比。
5.重写onTouchEvent方法,拦截ACTION_DOWN状态。得到点击的坐标,判断该点是否小于半径,如果大于半径则不处理,如果小于半径则计算该点和圆心的连线与x正方形的夹角,最后再遍历List<PieEntry>判断该夹角在那个扇形区域中,由此将点击时间回掉出去。

大题思路就是这样,文字描述可能还描述的不够明白,下面看代码怎么一步一步实现。

具体代码实现

1.创建MyPieChart类和PieEntry类。
public class MyPieChart extends View {

    private List<PieEntry> pieEntries;
    private Paint paint; //画笔
    private float centerX;   //中心点 x坐标
    private float centerY;  //中心点 y坐标
    private float radius;    //未选中状态的半径
    private float sRadius; //选中状态的半径

    /**
     * 每个扇形的对象
     */
    public static class PieEntry {
        private float number;  //数值
        private int colorRes;  //颜色资源
        private boolean selected; //是否选中
        private float startC;     //对应扇形起始角度
        private float endC;       //对应扇形结束角度

        public PieEntry(int number, int colorRes, boolean selected) {
            this.number = number;
            this.colorRes = colorRes;
            this.selected = selected;
        }

        public float getStartC() {
            return startC;
        }

        public void setStartC(float startC) {
            this.startC = startC;
        }

        public float getEndC() {
            return endC;
        }

        public void setEndC(float endC) {
            this.endC = endC;
        }

        public boolean isSelected() {
            return selected;
        }

        public void setSelected(boolean selected) {
            this.selected = selected;
        }

        public float getNumber() {
            return number;
        }

        public void setNumber(float number) {
            this.number = number;
        }

        public int getColorRes() {
            return colorRes;
        }

        public void setColorRes(int colorRes) {
            this.colorRes = colorRes;
        }
    }
}
2. onDraw方法的具体实现。

这里是onDraw方法的具体实现,代码中有详细的注释。其中涉及到一些三角函数知识,如果看不明白的话,可以参考下面的图示。


    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //计算总值
        int total = 0;
        for (int i = 0; i < pieEntries.size(); i++) {
            total += pieEntries.get(i).getNumber();
        }
        //刷新中心点 和半径
        centerX = getPivotX();
        centerY = getPivotY();
        if (sRadius == 0) {   //这里做个判断,如果没有通过setRadius方法设置半径,则半径为真个view最小边的一半
            sRadius = (getWidth() > getHeight() ? getHeight() / 2 : getWidth() / 2);
        }
        //计算出两个状态的半径,这里二者相差5dp.
        radius = sRadius - DensityUtils.dp2px(getContext(), 5);

        //其实角度设置为0,即x轴正方形
        float startC = 0;
        //遍历List<PieEntry> 开始画扇形
        for (int i = 0; i < pieEntries.size(); i++) {
            //计算当前扇形扫过的角度
            float sweep = 360 * (pieEntries.get(i).getNumber() / total);
            //设置当前扇形的颜色
            paint.setColor(getResources().getColor(pieEntries.get(i).colorRes));
            //判断当前扇形是否被选中,确定用哪个半径
            float radiusT;
            if (pieEntries.get(i).isSelected()) {
                radiusT = sRadius;
            } else {
                radiusT = radius;
            }
            //画扇形的方法
            RectF rectF = new RectF(centerX - radiusT, centerY - radiusT, centerX + radiusT, centerY + radiusT);
            canvas.drawArc(rectF, startC, sweep, true, paint);

            //下面是画扇形外围的 短线和百分数值。

            float arcCenterC = startC + sweep / 2; //当前扇形弧线的中间点和圆心的连线 与 起始角度的夹角
            float arcCenterX = 0;  //当前扇形弧线的中间点 的坐标 x  以此点作为短线的起始点
            float arcCenterY = 0;  //当前扇形弧线的中间点 的坐标 y

            float arcCenterX2 = 0; //这两个点作为短线的结束点
            float arcCenterY2 = 0;
            //百分百数字的格式
            DecimalFormat numberFormat = new DecimalFormat("00.00");
            paint.setColor(Color.BLACK);

            //分象限 利用三角函数 来求出每个短线的起始点和结束点,并画出短线和百分比。
            //具体的计算方法看下面图示介绍
            if (arcCenterC >= 0 && arcCenterC < 90) {
                arcCenterX = (float) (centerX + radiusT * Math.cos(arcCenterC * Math.PI / 180));
                arcCenterY = (float) (centerY + radiusT * Math.sin(arcCenterC * Math.PI / 180));
                arcCenterX2 = (float) (arcCenterX + DensityUtils.dp2px(getContext(), 10) * Math.cos(arcCenterC * Math.PI / 180));
                arcCenterY2 = (float) (arcCenterY + DensityUtils.dp2px(getContext(), 10) * Math.sin(arcCenterC * Math.PI / 180));
                canvas.drawLine(arcCenterX, arcCenterY, arcCenterX2, arcCenterY2, paint);
                canvas.drawText(numberFormat.format(pieEntries.get(i).getNumber() / total * 100) + "%", arcCenterX2, arcCenterY2 + paint.getTextSize() / 2, paint);
            } else if (arcCenterC >= 90 && arcCenterC < 180) {
                arcCenterC = 180 - arcCenterC;
                arcCenterX = (float) (centerX - radiusT * Math.cos(arcCenterC * Math.PI / 180));
                arcCenterY = (float) (centerY + radiusT * Math.sin(arcCenterC * Math.PI / 180));
                arcCenterX2 = (float) (arcCenterX - DensityUtils.dp2px(getContext(), 10) * Math.cos(arcCenterC * Math.PI / 180));
                arcCenterY2 = (float) (arcCenterY + DensityUtils.dp2px(getContext(), 10) * Math.sin(arcCenterC * Math.PI / 180));
                canvas.drawLine(arcCenterX, arcCenterY, arcCenterX2, arcCenterY2, paint);
                canvas.drawText(numberFormat.format(pieEntries.get(i).getNumber() / total * 100) + "%", (float) (arcCenterX2 - paint.getTextSize() * 3.5), arcCenterY2 + paint.getTextSize() / 2, paint);
            } else if (arcCenterC >= 180 && arcCenterC < 270) {
                arcCenterC = 270 - arcCenterC;
                arcCenterX = (float) (centerX - radiusT * Math.sin(arcCenterC * Math.PI / 180));
                arcCenterY = (float) (centerY - radiusT * Math.cos(arcCenterC * Math.PI / 180));
                arcCenterX2 = (float) (arcCenterX - DensityUtils.dp2px(getContext(), 10) * Math.sin(arcCenterC * Math.PI / 180));
                arcCenterY2 = (float) (arcCenterY - DensityUtils.dp2px(getContext(), 10) * Math.cos(arcCenterC * Math.PI / 180));
                canvas.drawLine(arcCenterX, arcCenterY, arcCenterX2, arcCenterY2, paint);
                canvas.drawText(numberFormat.format(pieEntries.get(i).getNumber() / total * 100) + "%", (float) (arcCenterX2 - paint.getTextSize() * 3.5), arcCenterY2, paint);
            } else if (arcCenterC >= 270 && arcCenterC < 360) {
                arcCenterC = 360 - arcCenterC;
                arcCenterX = (float) (centerX + radiusT * Math.cos(arcCenterC * Math.PI / 180));
                arcCenterY = (float) (centerY - radiusT * Math.sin(arcCenterC * Math.PI / 180));
                arcCenterX2 = (float) (arcCenterX + DensityUtils.dp2px(getContext(), 10) * Math.cos(arcCenterC * Math.PI / 180));
                arcCenterY2 = (float) (arcCenterY - DensityUtils.dp2px(getContext(), 10) * Math.sin(arcCenterC * Math.PI / 180));
                canvas.drawLine(arcCenterX, arcCenterY, arcCenterX2, arcCenterY2, paint);
                canvas.drawText(numberFormat.format(pieEntries.get(i).getNumber() / total * 100) + "%", arcCenterX2, arcCenterY2, paint);
            }
            //将每个扇形的起始角度 和 结束角度 放入对应的对象
            pieEntries.get(i).setStartC(startC);
            pieEntries.get(i).setEndC(startC + sweep);
            //将当前扇形的结束角度作为下一个扇形的起始角度
            startC += sweep;
        }
    }
扇形的绘画图解 扇形的绘画逻辑.png
扇形的绘画逻辑.png
扇形周围短线和百分比的绘制逻辑
扇形周围短线和百分比的绘制逻辑.png
扇形周围短线和百分比的绘制逻辑.png

3. onTouchEvent方法的具体实现。

该方法主要是监听点击事件,从获取哪个扇形被点击到了。

@Override
    public boolean onTouchEvent(MotionEvent event) {
        float touchX;
        float touchY;
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                touchX = event.getX(); //touch点的坐标
                touchY = event.getY();
                //判断touch点到圆心的距离 是否小于半径
                if (Math.pow(touchX - centerX, 2) + Math.pow(touchY - centerY, 2) <= Math.pow(radius, 2)) {
                    //计算 touch点和圆心的连线 与 x轴正方向的夹角
                    float touchC = getSweep(touchX, touchY);
                    //遍历 List<PieEntry> 判断touch点在哪个扇形中
                    for (int i = 0; i < pieEntries.size(); i++) {
                        if (touchC >= pieEntries.get(i).getStartC() && touchC < pieEntries.get(i).getEndC()) {
                            pieEntries.get(i).setSelected(true);
                            if (listener != null)
                                listener.onItemClick(i); //将被点击的扇形id回调出去
                        } else {
                            pieEntries.get(i).setSelected(false);
                        }
                    }
                    invalidate();//刷新画布
                }
                break;
        }

        return super.onTouchEvent(event);
    }

    /**
     * 获取  touch点/圆心连线  与  x轴正方向 的夹角
     *
     * @param touchX
     * @param touchY
     */
    private float getSweep(float touchX, float touchY) {
        float xZ = touchX - centerX;
        float yZ = touchY - centerY;
        float a = Math.abs(xZ);
        float b = Math.abs(yZ);
        double c = Math.toDegrees(Math.atan(b / a));
        if (xZ >= 0 && yZ >= 0) {//第一象限
            return (float) c;
        } else if (xZ <= 0 && yZ >= 0) {//第二象限
            return 180 - (float) c;
        } else if (xZ <= 0 && yZ <= 0) {//第三象限
            return (float) c + 180;
        } else {//第四象限
            return 360 - (float) c;
        }
    }
touch点和圆心连线 与 x轴正方向的夹角 计算逻辑
touch点和圆心连线 与 x轴正方向的夹角.png
touch点和圆心连线 与 x轴正方向的夹角.png

使用

大功告成,下面看一下怎么使用。 很简单,跟普通的view使用差不多

xml中设置控件的大小
 <com.sharker.view.MyPieChart
                    android:id="@+id/pie_chart"
                    android:layout_width="match_parent"
                    android:layout_height="190dp" />
在java代码中使用
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        // TODO: add setContentView(...) invocation
        setContentView(R.layout.xxx);
        
        MyPieChart pieChart = (MyPieChart) findViewById(R.id.pie_chart);
        pieChart.setRadius(DensityUtils.dp2px(getContext(), 75));
        pieChart.setOnItemClickListener(new MyPieChart.OnItemClickListener() {
            @Override
            public void onItemClick(int position) {
                
            }
        });
        List<MyPieChart.PieEntry> pieEntries = new ArrayList<>();
        pieEntries.add(new MyPieChart.PieEntry(1, R.color.chart_orange, true));
        pieEntries.add(new MyPieChart.PieEntry(2, R.color.chart_green, false));
        pieEntries.add(new MyPieChart.PieEntry(3, R.color.chart_blue, false));
        pieEntries.add(new MyPieChart.PieEntry(4, R.color.chart_purple, false));
        pieEntries.add(new MyPieChart.PieEntry(5, R.color.chart_mblue, false));
        pieEntries.add(new MyPieChart.PieEntry(6, R.color.chart_turquoise, false));
        pieChart.setPieEntries(pieEntries);
    }

Ok,自定义的饼状图完成。有哪些做的不合适的地方,希望大家多多指点。 有需要源码的小伙伴,留下邮箱即可。