曰:这文章写得很不咋地,但是却是自己“开悟”的记录,不想浑浑噩噩,首先不去浑浑噩噩!

前两天看到朱凯大神发表了酝酿一整年的大作:《HenCoder:给高级 Android 工程师的进阶手册》,作为一个码农不敢妄看高级之物,但看在朱凯大神久处于朱大嫂淫威之下,关顾一下以示支持,不曾想到大神的文章是以细微处见真知,回到基础知识上,真是久旱逢甘露,挣扎已久的心突然静了下来,慢慢找回“多敲代码少BiBi”的正经路上…

这饼图view是在做《Android 开发进阶: 自定义 View 1-1 绘制基础》的练习时突发奇想来的(说是突发奇想是因为浮躁久了,不愿自己思考…),本来在练习画饼图,一个个画不难,突然想到,是否可以写一个通用的view,只要输入一组数据,就可以画出相应的饼图,但又一想,网上好看又好用的轮子那么多,为什么要自找麻烦,但又又一想,自己多久没思考了!?什么都是“有的用拿来就用”,明明知道对自己有益的做法,都因为自己的懒惰而不愿动手去做,拿“太麻烦”、“太难”等等等等来推脱自己…

灵光一闪,说干就干!

首先:理清思路

android 扇形按钮 android画扇形_android 扇形按钮

看图,首先将饼图切割成几个模块:扇形图、线、文字
完了…(这思路有点那个…)

@Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        
        drawSectors(canvas);
        drawLines(canvas);
        drawTexts(canvas);
    }
// 模拟数据
    private Map<String, Float> mDataMap = new LinkedHashMap<>(); 
// 需要按顺序,所以用 LinkedHashMap

    {
        mDataMap.put("Froyo", 2f);
        mDataMap.put("Gingerbread", 6f);
        mDataMap.put("ice Cream Sandwich", 5f);
        mDataMap.put("Jelly Bean", 50f);
        mDataMap.put("KitKat", 80f);
        mDataMap.put("Lollipop", 110f);
        mDataMap.put("Marshmallow", 40f);
    }

模块一:扇形图

/**
     * 画扇形
     **/
    private void drawSectors(Canvas canvas) {
        mPaint.setStyle(Paint.Style.FILL); // 填充模式

        mSectorsDataList = calculateSectorsDatas();
        SectorsData sectorsData;
        for (int i = 0; i < mSectorsDataList.size(); i++) {
            sectorsData = mSectorsDataList.get(i);
            mPaint.setColor(mColors.get(i));
            canvas.drawArc(sectorsData.left, sectorsData.top, sectorsData.right, sectorsData.bottom, sectorsData.startAngle, sectorsData.sweepAngle, true, mPaint);
        }
    }

这里的要点在于计算各个数据所占的比例,根据比例计算扫过的角度及作画(Draw)的坐标

/**
     * 计算各扇形的坐标、角度
     **/
    private List<SectorsData> calculateSectorsDatas() {
        List<SectorsData> sectorsDataList = new ArrayList<>();
        float startAngle = 0; // 开始角度
        float sweepAngle; // 扇形角度
        float sum = 0;
        for (String key : mDataMap.keySet()) {
            sum += mDataMap.get(key);
        }
        float maxValue = getMaxValue(mDataMap);
        for (String key : mDataMap.keySet()) {
            // 突出最大的块
            if (mDataMap.get(key) == maxValue) {
                mSkewingLength = 30f;
            } else {
                mSkewingLength = 10f;
            }
            sweepAngle = (mDataMap.get(key) / sum) * 360;
            SectorsData sectorsData = calculateDirectionCoord(startAngle, sweepAngle);
            sectorsData.startAngle = startAngle;
            sectorsData.sweepAngle = sweepAngle;
            sectorsDataList.add(sectorsData);
            startAngle += sectorsData.sweepAngle;
        }
        return sectorsDataList;
    }
/**
     * 扇形数据类
     **/
    private class SectorsData {
        float left;
        float top;
        float right;
        float bottom;
        float startAngle;
        float sweepAngle;
        float middleAngle;
        float sectorsX;
        float sectorsY;
    }

其中,饼形图的圆点坐标已此PieView的宽高决定,取中心点(可根据要求另取中心点)

@Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

        // 获取当前view的宽高
        mWidth = getMeasuredWidth();
        mHeight = getMeasuredHeight();

        // 扇形中心点
        mSectorsX = mWidth / 2;
        mSectorsY = mHeight / 2;
    }

这里的难点是计算扇形的偏移量,从样图可看出,各扇形并不是一一相连,而是有一定的偏移量,偏移量暂且不表,先说说偏移方向,看图:

android 扇形按钮 android画扇形_饼图_02

左图,如果扇形往不同方向偏移,就会造成某些饼图相叠,而某些扇形偏离主体较远,那么就看起来很丑,所以要规定某一个方向,而如果方向相同,那么就是整个图的移动,做不出想要的结果;
右图,如果朝每个扇形的中线方向移动,那么,每个扇形与相邻的两个扇形的距离都一样,就不会相叠,且偏移发散后的主体图形外观完整(偏移量不能过多)。

扇形的中心线角度由开始角度startAngle、扫过角度sweepAngle计算后可知。

/**
     * 根据扇形角度计算扇形偏移方向及最终坐标
     */
    private SectorsData calculateDirectionCoord(float startAngle, float sweepAngle) {

        SectorsData sectorsData = new SectorsData();
        sectorsData.middleAngle = (startAngle + sweepAngle / 2); // 中间角度,用于计算偏移方向角度

        float skewingX; // 偏移x量
        float skewingY; // 偏移Y量
        // 已经斜边和角度: 角度的对边 = 斜边*sin角度 ; 角度邻边 = 斜边*cos角度
        // TODO : 角度转弧度 π/180×角度 【Math.cos、Math.sin参数是弧度 !】
        skewingX = (float) (mSkewingLength * Math.cos(sectorsData.middleAngle * Math.PI / 180));
        skewingY = (float) (mSkewingLength * Math.sin(sectorsData.middleAngle * Math.PI / 180));

        sectorsData.left = mSectorsX - mRadius + skewingX;
        sectorsData.top = mSectorsY - mRadius + skewingY;
        sectorsData.right = mSectorsX + mRadius + skewingX;
        sectorsData.bottom = mSectorsY + mRadius + skewingY;

        sectorsData.sectorsX = mSectorsX + skewingX;
        sectorsData.sectorsY = mSectorsY + skewingY;

        return sectorsData;
    }

这里又有另外一个重点:偏移后的扇形圆点坐标会改变,**那么偏移量是多少?即x、y改变了多少,怎么算?**看图:

android 扇形按钮 android画扇形_自定义view_03


古语有云:已经斜边和角度,则角度的对边 = 斜边 * sin角度 ,角度的邻边 = 斜边 * cos角度

所以可求x,y的偏移量:

// 角度转弧度:π/180 × 角度 【Math.cos、Math.sin 参数是弧度 !】
        skewingX = (float) (mSkewingLength * Math.cos(sectorsData.middleAngle * Math.PI / 180));
        skewingY = (float) (mSkewingLength * Math.sin(sectorsData.middleAngle * Math.PI / 180));

这里要注意的是:Math.cos、Math.sin 计算时,参数是弧度,而不是角度(我就被这里坑了,一直计算出来的结果跟自己在计算机计算的结果不同,后面一查,呵呵)

这里提一下:为了突出最大的扇形块,获取一下最大的value值

/**
     * 获取 map中 value的最大值
     **/
    private float getMaxValue(Map<String, Float> map) {
        Float max = 0f;
        for (Map.Entry<String, Float> entry : map.entrySet()) {
            if (entry.getValue() > max) {
                max = entry.getValue();
            }
        }
        return max;
    }
// 突出最大的块
            if (mDataMap.get(key) == maxValue) {
                mSkewingLength = 30f;
            } else {
                mSkewingLength = 10f;
            }

模块二:线

画线和文字的思路与画扇形的思路一样,只是线的起始点要基于扇形,文字的落点要基于线。

/**
     * 画线
     **/
    private void drawLines(Canvas canvas) {
        mPaint.setStyle(Paint.Style.STROKE);
        mPaint.setStrokeWidth(3f);
        mPaint.setColor(Color.WHITE);

        mLinesDataList = calculateLinesDatas();
        LinesData linesData;
        for (int i = 0; i < mLinesDataList.size(); i++) {
            linesData = mLinesDataList.get(i);
            mPath.moveTo(linesData.startX, linesData.startY);
            mPath.lineTo(linesData.turnX, linesData.turnY);
            mPath.lineTo(linesData.endX, linesData.endY);
            canvas.drawPath(mPath, mPaint);
        }
    }

线的终点 y 坐标跟线的拐点 / 转折点的 y 坐标一致,这样就能画出水平线;
线的终点 x 坐标以中心点 x 坐标为基点,这样就能做出样图的效果:各线终点保持在同一垂直线上,而根据中心线的角度不同,来计算是放在左边还是右边。

/**
     * 计算各线的 Path
     **/
    private List<LinesData> calculateLinesDatas() {

        List<LinesData> linesDataList = new ArrayList<>();

        for (int i = 0; i < mDataMap.size(); i++) {
            LinesData linesData = new LinesData();
            SectorsData sectorsData = mSectorsDataList.get(i);

            // 线的起点
            float startX;
            float startY;
            startX = (float) (mRadius * Math.cos(sectorsData.middleAngle * Math.PI / 180));
            startY = (float) (mRadius * Math.sin(sectorsData.middleAngle * Math.PI / 180));
            linesData.startX = sectorsData.sectorsX + startX;
            linesData.startY = sectorsData.sectorsY + startY;

            // 线的转折点
            float turnX;
            float turnY;
            turnX = (float) ((mRadius + 50) * Math.cos(sectorsData.middleAngle * Math.PI / 180));
            turnY = (float) ((mRadius + 50) * Math.sin(sectorsData.middleAngle * Math.PI / 180));
            linesData.turnX = sectorsData.sectorsX + turnX;
            linesData.turnY = sectorsData.sectorsY + turnY;

            // 线的终点
            if (sectorsData.middleAngle > 90 && sectorsData.middleAngle < 270) {
                linesData.endX = mSectorsX - mRadius - 100;
            } else {
                linesData.endX = mSectorsX + mRadius + 100;
            }
            linesData.endY = linesData.turnY;
            linesData.middleAngle = sectorsData.middleAngle;

            linesDataList.add(linesData);
        }

        return linesDataList;
    }
/**
     * 线数据类
     **/
    private class LinesData {
        float startX;
        float startY;
        float turnX;
        float turnY;
        float endX;
        float endY;
        float middleAngle;
    }

模块三:文字

/**
     * 画文字
     **/
    private void drawTexts(Canvas canvas) {
        mPaint.setStyle(Paint.Style.FILL);
        mPaint.setColor(Color.WHITE);
        mPaint.setTextSize(30f);

        mTextsDataList = calculateTextsDatas();
        TextsData textsData;
        for (int i = 0; i < mTextsDataList.size(); i++) {
            textsData = mTextsDataList.get(i);
            mPaint.setTextAlign(textsData.PAINT_ALIGN);
            canvas.drawText(textsData.name, textsData.startX, textsData.startY, mPaint);
        }
        mPaint.setTextAlign(Paint.Align.CENTER); // 设置坐标点在字符的中心
        mPaint.setTextSize(60f);
        canvas.drawText("饼图", mSectorsX, mSectorsY + mRadius / 2 * 3, mPaint);
    }

这里提一点:
依照线的终点来画文字,画文字有一个位置属性可以设置(看源码),可设置从文字的左边 / 中间 / 右边开始画

mPaint.setTextAlign(Paint.Align.LEFT);
mPaint.setTextAlign(Paint.Align.CENTER);
mPaint.setTextAlign(Paint.Align.RIGHT);

根据中心线的角度不同,来计算是放在左边还是右边。

/**
     * 计算各文字的坐标
     **/
    private List<TextsData> calculateTextsDatas() {

        List<TextsData> textsDataList = new ArrayList<>();

        for (int i = 0; i < mDataMap.size(); i++) {
            TextsData textsData = new TextsData();
            LinesData linesData = mLinesDataList.get(i);
            // 根据线的终点计算文字的坐标
            if (linesData.middleAngle > 90 && linesData.middleAngle < 270) {
                textsData.startX = linesData.endX - 10;
                textsData.PAINT_ALIGN = Paint.Align.RIGHT;
            } else {
                textsData.startX = linesData.endX + 10;
                textsData.PAINT_ALIGN = Paint.Align.LEFT;
            }
            textsData.startY = linesData.turnY;

            textsDataList.add(textsData);
        }

        List<String> nameList = new ArrayList(mDataMap.keySet());
        for (int i = 0; i < nameList.size(); i++) {
            textsDataList.get(i).name = nameList.get(i);
        }

        return textsDataList;
    }
/**
     * 文字数据类
     **/
    private class TextsData {
        Paint.Align PAINT_ALIGN;
        String name;
        float startX;
        float startY;
    }

最终结果:

android 扇形按钮 android画扇形_自定义view_04

这个view并不难,写得也不咋滴,还有待优化,但爱咋咋滴 …