自定义View进阶路:绘制饼图

2019-06-11 17:33:34 浏览数 (1)

前言

首先,附上效果图,方便大家一眼可以查看是不是自己想要的~

大家看到效果了吧,要实现这个效果也不难,最重要的一点就是心中有数,那么如何做到心中有数呢?通俗来讲,也就是掌握实现流程,那么如何掌握呢?往下瞧~

一、分解步骤

首先从最直白的面上来分析,LZ这里简单分为三个区域,如下:

  • 最外侧文本绘制与显示;
  • 中间层小短线绘制与显示;
  • 内侧由扇形组合成的圆形。

简单的分析如上,那么实现的流程却恰恰相反,这里LZ再次说明一下:

  • 首先,绘制扇形,之后,组合成圆形;
  • 其次,绘制小短线;
  • 最后,绘制文本并显示。

二、绘制前准备操作

1. 老规矩,继承View,添加初始化画笔方法并重写onDraw()方法:

代码语言:javascript复制
/**
 * 饼图 Created by HLQ on 2017/8/22
 * 实现步骤如下:
 * 1. 绘制扇形并组合成圆形;
 * 2. 绘制中间短线;
 * 3. 绘制外侧文本
 */
public class PieChartView extends View {

    public PieChartView(Context context) {
        this(context, null);
    }

    public PieChartView(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public PieChartView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        initPaint();
    }

    private void initPaint() {

    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

    }

}

2. 数据源设定

首先我们根据效果图进行分析,要想显示饼图以及比例,我们应该怎么办?或者说是我们需要哪儿些数据?

首先,我们肯定会需要一个占比,这里的意思代表在整个圆显示的一个比例,其次,如果需要根据不同的比例显示不同的颜色区域,我们同样也需要一个颜色值。

简单分析后,得出,我们需要一个Bean,用于接收后台返回数据或者存放模拟数据,而它所需属性如下:

  • value; // 绘制扇形区域的占比;
  • color; // 绘制扇形区域颜色

So,根据以上结论,创建实体类,如下:

代码语言:javascript复制
package cn.hlq.customview.bean;

/**
 * 饼图实体类 Created by HLQ on 2017/8/23
 */
public class PieCharBean {

    public float value; // 绘制扇形区域的占比 个数相加*360°
    public int color; // 绘制扇形区域颜色

    public PieCharBean(float value, int color) {
        this.value = value;
        this.color = color;
    }
}

接收数据源的实体类有了,那么我们如何通知自定义View中呢?

不用说,鸡贼的小伙伴肯定会说,自定义View里面添加一个方法接收不就得了嘛~

OK,就是这么搞,在自定义View中新增接收方法,如下:

代码语言:javascript复制
   private List<PieCharBean> mPieCharList; // 数据源

    /**
     * 设置数据源
     *
     * @param pieCharList
     */
    public void setPieChartData(List<PieCharBean> pieCharList) {
        this.mPieCharList = pieCharList;
    }

到这里,我们的自定义View已添加了获取数据源的方法,那么具体怎么使用呢?LZ简单说明下:

  • Activity中引入我们的自定义View,初始化;
  • 模拟数据源并传递到自定义View中即可。

引入View这块忽略,直接上Activity初始化View以及传递数据源。

代码语言:javascript复制
package cn.hlq.androidcustomview.activities;

import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;

import java.util.ArrayList;
import java.util.List;

import cn.hlq.androidcustomview.R;
import cn.hlq.customview.PieChartView;
import cn.hlq.customview.bean.PieCharBean;

public class PieChartActivity extends AppCompatActivity {

    private PieChartView pieChartView;

    /**
     * 扇形显示的颜色值
     */
    private int[] colors={0xFFCCFF00,0xFF6495ED,0xFFE32636,0xFF800000,0xFF808000,0xFFFF8C69,0xFF808080,0xFFE68800,0xFF7CFC00};

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_pie_chart);
        initView();
    }

    /**
     * 模拟获取数据源
     * @return
     */
    private List<PieCharBean> getPieCharData(){
        List<PieCharBean> pieCharList=new ArrayList<>();
        for (int i = 0; i < 9; i  ) {
            pieCharList.add(new PieCharBean(i 1,colors[i]));
        }
        return pieCharList;
    }

    private void initView() {
        pieChartView= (PieChartView) findViewById(R.id.id_pie_chart);
        pieChartView.setPieChartData(getPieCharData());
    }
}

到这里,我们已经完成了对数据源的简单模拟以及自定义View接收,下面,我们要真正开始绘制我们的饼图~

三、继续分析与绘制饼图

一、分析

首先放个图,基于图,我们进行讲解说明,如下:

如上图,一个简易效果,饼图位于屏幕中间,那么我们该如何绘制呢?

  • 确定绘制的起点,也就是确定饼图的中心点;

大家知道,默认的坐标系位于屏幕的左上角,分别X,Y轴,移动之后,坐标系便位于屏幕中间。关于如何移动,我们下面将进行撸码说明。

  • 计算外接矩形距屏幕的左上右下;

计算外接矩形,也就是计算移动到屏幕中心。而我们就是要计算扇形组成圆形的外接矩形的左上右下距离。

  • 计算每块扇形的弧度;
  • 遍历接收到的数据源,这里需要注意一点,每个扇形的起始角度,都是上一个扇形的结束角度。这里有人会问了,那第一个扇形的绘制角度在哪儿呢?别急,下面为你解答。

第一个扇形的绘制角度当然位于移动后的中心点,通过不断变更临时存储变量内容去不断更新起始角度即可。

二、撸码

这里为大家介绍一个方法,如下:

代码语言:javascript复制
/**
     * 当自定义控件的尺寸已经确定好调用
     *
     * @param w    宽度
     * @param h    高度
     * @param oldw
     * @param oldh
     */
    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
    }

方法的含义代码中已说明,我们首先在onDraw()设置一些常规操作(这里也就是指的上面说的移动坐标系到屏幕中心点)

代码语言:javascript复制
   @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        // 保存Canvas的状态
        canvas.save();
        // 移动坐标系到屏幕中心点 宽高各取一半
        canvas.translate(mWidth / 2, mHeight / 2);
        // 用来恢复Canvas旋转、缩放等之后的状态,当和canvas.save( )一起使用时,恢复到canvas.save( )保存时的状态
        canvas.restore();
    }

获取外接矩形左上右下坐标点

关于获取左上右下坐标点的时候,需要注意,由于我们获取的是屏幕二分之一大小,但是我们外部还有短线以及文本显示,如果还是按照之前逻辑,会导致短线以及文本显示不全或者压根显示在屏幕外侧,所以在这里,取点应该为屏幕半径的百分之70或者百分之80,这里大家明白了吗?具体代码如下:

代码语言:javascript复制
   private int mRadius; // 半径
    private RectF mRectF; // 定义扇形的外接矩形

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        this.mWidth = w;
        this.mHeight = h;
        // 防止绘制后View超出屏幕大小,首先获取屏幕宽高的较小值
        int min = Math.min(w, h);// 获取到直径
        mRadius = (int) (min * 0.7f / 2); // 获取半径

        mRectF.left = -mRadius; // 左
        mRectF.top = -mRadius; // 上
        mRectF.right = mRadius; // 右
        mRectF.bottom = mRadius; // 下

    }

到现在为止,我们已经对扇形外接矩形处理完毕,是不是感觉很easy?

接下来,我们开始处理扇形绘制。

首先,在onDraw()定义绘制方法,方便我们进行扇形绘制。绘制前,我们需要初始化一个画笔,如下:

代码语言:javascript复制
   private Paint mPaint;

    // 以下初始化放置在我们开始定义好的initPaint()即可
    mPaint = new Paint();
    mPaint.setAntiAlias(true); // 开启抗锯齿

接下来,获取扇形区域的总数,方便我们计算每个扇形占比。

代码语言:javascript复制
   /**
     * 设置数据源
     *
     * @param pieCharList
     */
    public void setPieChartData(List<PieCharBean> pieCharList) {
        this.mPieCharList = pieCharList;
        for (PieCharBean pieCharBean : mPieCharList) {
            mTotalValue  = pieCharBean.value;
        }
    }

之后开始绘制扇形。

代码语言:javascript复制
   /**
     * 绘制扇形
     *
     * @param canvas
     */
    private void drawPieChart(Canvas canvas) {
        float startAngle = 0;
        for (int i = 0; i < mPieCharList.size(); i  ) {
            // 移动到0,0点
            mPath.moveTo(0, 0);
            PieCharBean pieCharBean = mPieCharList.get(i);
            // 绘制当前扇形区域颜色
            mPaint.setColor(mPieCharList.get(i).color);
            float sweepAngle = pieCharBean.value / mTotalValue * 360;
            mPath.arcTo(mRectF, startAngle, sweepAngle);
            // 绘制扇形
            canvas.drawPath(mPath, mPaint);
        }
    }

下面我们来运行下效果:

咦,大家这里可能会有疑问了,怎么是这样子呢?别急,这里为大家引入一个新的内容。

代码语言:javascript复制
// 绘制扇形后 要对Path进行重置操作 这样可以清除上一次画笔的缓存
mPath.reset();

设置完成后,我们再次运行查看效果:

这里为大家简单说明一下为什么会出现刚才那样的原因。

当我们第一次绘制扇形的时候,Path中记录了当前的属性,也就是颜色值,在绘制下一个扇形的时候,由于Path中依然缓存或者说是记录第一次属性值时,造成的后果就是我们看到的一个颜色的大圆形。

So,我们只需调用reset()使其重置便可达到我们想要的效果。

细心的伙计发现了,效果图展示的时候,每个扇形之间有个间隔,怎么这个没有呢?不急不急,听我细细道来。

大家仔细观察上图,左侧有颜色填充部分绘制时角度减一,而右侧绘制角度加一,结合代码,是不是中间正好留有一角度空间?不信?那我们就试试。

将之前部分代码修改如下:

代码语言:javascript复制
// 默认绘制扇形
float sweepAngle = pieCharBean.value / mTotalValue * 360 - 1;

// 每个扇形区域的起始点都是上一个扇形区域的终点
startAngle  = sweepAngle   1;

查看效果:

就问服不服?

四、绘制直线

先附上一张简易图,下面将根据简易图进行简单说明。 

绘制直线的要求:

  • 直线的反向延长线经过圆心; 
  • 直线与圆的焦点一定在对应扇形终点处; 
  • 绘制直线的颜色应保持一致

绘制直线的俩个要素:

  1. 直线的起点:  lineStartAngle=startAngle sweepAngle/2;  x=radius*Math.cos(Math.toRadians(lineStartAngle)) 半径*余弦值  y=radius*Math.sin(Math.toRadians(lineStartAngle)) 半径*正弦值 
  2. 直线的终点: 计算同起点计算:将原来的radius 30

那么根据如上分析,在onDraw中新增如下代码:

代码语言:javascript复制
double angdeg = Math.toRadians(startAngle   sweepAngle / 2);
float startX = (float) (mRadius * Math.cos(angdeg));
float startY = (float) (mRadius * Math.sin(angdeg));
float endX = (float) ((mRadius   30) * Math.cos(angdeg));
float endY = (float) ((mRadius   30) * Math.sin(angdeg));
// 绘制线条
canvas.drawLine(startX, startY, endX, endY, mPaint);

运行查看效果:

图片缩小后,显示的效果不是很清晰,大家可以看到,绘制的直线的颜色和当前扇形颜色一致,效果看起来还是不错的。但是我们的需求是直线颜色一致,So,初始化一根画笔,分分钟搞定。

代码语言:javascript复制
// 初始化画笔
mLinePaint = new Paint();
mLinePaint.setColor(Color.BLACK); // 设置画笔颜色为黑色
mLinePaint.setAntiAlias(true);    // 设置抗锯齿
mLinePaint.setStrokeWidth(3);     // 设置线条宽度

// 替换原有画笔
canvas.drawLine(startX, startY, endX, endY, mLinePaint);

So,显示如下:

五、绘制文本

绘制文本需要注意要点:

  1. 绘制文本的内容(计算占比); 
  2. 绘制文本的位置
代码语言:javascript复制
// 绘制文本
String percent = String.format("%.1f", pieCharBean.value / mTotalValue * 100);
canvas.drawText(percent, endX, endY, mLinePaint);

搂一眼效果:

看到效果,大家会有疑问,右半部份显示正常,左半部分怎么都跑里面去了?别急,我们一步步解决。

现在的问题在于左侧显示文本有问题,那么我们可不可以设置一个角度,也就是覆盖左侧点的一个角度值,如果满足当前情况,文本向左移动一个位置,否则正常显示。来,一起试试。

代码语言:javascript复制
// 绘制文本
String percent = String.format("%.1f", pieCharBean.value / mTotalValue * 100);
percent = percent   "%";
if (startAngle % 360.0f >= 90.0f && startAngle % 360.0f <= 270.0f) {
  float textWidth = mLinePaint.measureText(percent);
  canvas.drawText(percent, endX - textWidth, endY, mLinePaint);
} else {
  canvas.drawText(percent, endX, endY, mLinePaint);
}

效果如下:

嗯哼,是不是显示正常了?

六、点击事件处理

首先,回头看,文章的开头效果点击区域会稍微突出一点,那个怎么弄呢?别急。

首先实现之前我们要明白,在我们的自定义View中,也就是我们绘制的饼图中,我点击了某一块,饼图是怎么知道我点击的哪儿块呢?

大家可能回想了,onClick事件呗,但是,我们的饼图属于一个整体,你还是不知道你点击的是具体哪儿一块。

别急,别急,我们一点点实现。

这里先为大家引入一个事件,下面所有重点都将在这里处理。

代码语言:javascript复制
   /**
     * 用户与视图交互时触发
     *
     * @param event
     * @return
     */
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        // 获取用户当前对屏幕的操作
        int action = event.getAction();
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                // 获取点击区域

                break;
            case MotionEvent.ACTION_MOVE:

                break;
            case MotionEvent.ACTION_UP:

                break;
        }
        return super.onTouchEvent(event);
    }

下面我们分析下我们接下来要实现的原理,也就是重点。

将点击的坐标位置转化为以饼状图中心为原点的坐标,对坐标进行处理,之后将坐标转化为点击的角度,判断是否处于某一个饼状图所在的角度区域

接下来我们开始获取当前视图左边缘、上边缘以及圆心坐标。

代码语言:javascript复制
// 获取用户点击的位置距当前视图的左边缘距离
float x = event.getX();
// 获取用户点击的位置距当前视图的上边缘距离
float y = event.getY();
// 将点击的xy坐标转化为以饼图为圆心的坐标
x = x - mWidth / 2;
y = y - mHeight / 2;

那么我们如何获取用户点击角度呢?首先为大家介绍一个工具类,如下:

代码语言:javascript复制
package cn.hlq.customview.util;

/**
 * 数学工具类 Create by heliquan at 2017年8月23日
 */
public class MathUtil {

    /**
     * 获取xy坐标对应角度
     *
     * @param x
     * @param y
     * @return
     */
    public static float getTouchAngle(float x, float y) {
        float touchAngle = 0;
        if (x < 0 && y < 0) {  //2 象限
            touchAngle  = 180;
        } else if (y < 0 && x > 0) {  //1象限
            touchAngle  = 360;
        } else if (y > 0 && x < 0) {  //3象限
            touchAngle  = 180;
        }
        //Math.atan(y/x) 返回正数值表示相对于 x 轴的逆时针转角,返回负数值则表示顺时针转角。
        //返回值乘以 180/π,将弧度转换为角度。
        touchAngle  = Math.toDegrees(Math.atan(y / x));
        if (touchAngle < 0) {
            touchAngle = touchAngle   360;
        }
        return touchAngle;
    }
}

这个类的作用就是获取xy坐标对应角度。

接下来我们就可以获取点击角度以及触摸半径了。

代码语言:javascript复制
// 获取点击的角度
float touchAngle = MathUtil.getTouchAngle(x, y);
// 获取触摸的半径
float toucheRadius = (float) Math.sqrt(x * x   y * y);

到目前为止,我们应该把所有的扇形区域块存放到一个集合中,通过点击去判断当前点击区域是否为有效区域且当前点击区域所对顶的集合,也就是我们存在的扇形。

So,定义一个集合,用于存放所有扇形的起始角度,我们从服务端获取数据有多少条,对应的集合大小就有多少个,所以我们在遍历值得时候进行初始化。

在我们之前的setPieChartData()方法中新增初始化:

代码语言:javascript复制
mStartAngles = new float[pieCharList.size()];

当开始绘制扇形的时候,我们就应该将当前绘制扇形的起始角度缓存下来。

代码语言:javascript复制
// 存储每次开始角度
mStartAngles[i] = startAngle;

接下来,我们只需要对当前的集合以及点击触摸下的角度进行比对,看看点击是否是有效区域且确定当前点击扇形区域。

So,在onTouch中,增加校验。

代码语言:javascript复制
// 判断触摸的点距离饼状图<对应的圆心
if (toucheRadius < mRadius) { // 标识当前点击区域为有效区域
  // 查找触摸角度是否位于起始角度集合中
  // binarySearch:参数2在参数1对应的集合中的索引
  // {1,2,3}
  // 搜索1,返回值在集合中对应的索引为0
  // 未找到 则返回 -(搜索值附近的大于搜索值得正确值的索引值 1)
  // 搜索1.2 返回-1(1 1) -2
  int searchResult = Arrays.binarySearch(mStartAngles, touchAngle);
  // 防止点击边缘位置 增加判断
  if (searchResult < 0) {
    mPosition = -searchResult - 1 - 1;
  } else {
    mPosition = searchResult;
  }
}

在讲解下面内容的时候,首先依据下面图来说明。

我们饼图点击的效果是用户所点击的扇形区域稍微往外面延伸了部分,如上图的蓝色区域。

那么之前绘制扇形的时候,我们知道绘制的饼图外接矩形位于红色矩形内,而点击后,也就是相当于当前的外接矩形延伸了一部分,当然,我们之前重新定义一个外接矩形,来放置我们延伸后的某一块扇形区域。

代码语言:javascript复制
mTouchmRectF.left = -mRadius - 15; // 左
mTouchmRectF.top = -mRadius - 15; // 上
mTouchmRectF.right = mRadius   15; // 右
mTouchmRectF.bottom = mRadius   15; // 下

接下来,我们只需要在onDraw方法中通过之前我们存储的位置去判断什么时候绘制被触摸的扇形区域或绘制饼图。

代码语言:javascript复制
if (i == mPosition) {
  mPath.arcTo(mTouchmRectF, startAngle, sweepAngle);
} else {
  mPath.arcTo(mRectF, startAngle, sweepAngle);
}

代码编写到此,运行查看效果。

运行后,大家会发现怎么点击无效?

这里要为大家引入一个新的东西,当然,引入之前需要说明原因。

这里大家回想一下,我们虽然定义出了用户触摸的扇形区域,但是我们在每次点击,校验直到知道当前点击扇形区域,onDraw方法知道吗?或者说是,通知了onDraw方法,让其更新UI吗?并没有。

那么,我们该怎么通知onDraw呢?

如下,

代码语言:javascript复制
// 重绘
invalidate();

不要9.9,也不要998,只需要调用invalidate(),通知onDraw更新即可。不信?咱运行瞅瞅。

嗯哼,是不是有效果了?

大家注意到没,点击扇形后,扇形区域和直线有重叠部分,瞬间档次降低不少,那么怎么操作呢?别急,进入我们优化阶段~

七、饼图优化

基于上面说的问题,LZ这里为大家提供俩种思路。

  • 直接将直线的绘制起点在原有的起点值加上点击扇形后延伸的值。这样的原理就是,直接让直线的起点位于扇形点击后延伸后的终点,简介解决我们的问题;
  • 其次,也可以在点击当前扇形的通过,更新直线起点终点。

时间不早了,LZ简单基于第一种思路进行修改。

代码语言:javascript复制
float startX = (float) ((mRadius   15) * Math.cos(angdeg));
float startY = (float) ((mRadius   15) * Math.sin(angdeg));
float endX = (float) ((mRadius   40) * Math.cos(angdeg));
float endY = (float) ((mRadius   40) * Math.sin(angdeg));

效果如下:

八、GitHub查看地址

https://github.com/HLQ-Struggle/AndroidCustomView

0 人点赞