首先看一下效果图:
对于这个饼状图,我也没有说一开始就想好要做成什么样,只是单纯地想做一个。绘制圆弧部分很简单。但是只是画出几个圆弧肯定是不够的。于是我就又在外面加了一些文字。文字有了,我又想为何不在一开始的时候给它加个动画呢?于是就有了这个组合的动画。动画有了,我又想不如再加个点击事件吧。这样一个简单的饼状图就完成了。
1.前期准备
观察上面的gif图,我用了三个画笔(扇形,文字,折线)
折线的起点是扇形所在圆弧的中点,不同象限的折线,所指向的方向也需要不同,同时文字的显示是在折线终点的后面(例如在第四象限的蓝色区域,20%在折线的右边,第三象限的灰色区域,20%在折线的左边)
这个自定义View的大小如何确定呢?如果是确切的值,就不再处理。如果是MeasureSpec.AT_MOST,我就将整个View设置为圆直径的两倍。
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
val suggestWidth = MeasureSpec.getSize(widthMeasureSpec)
val suggestHeight = MeasureSpec.getSize(heightMeasureSpec)
if(widthMode == MeasureSpec.AT_MOST || heightMode == MeasureSpec.AT_MOST){
setMeasuredDimension(mRadius.toInt() * 4,mRadius.toInt() * 4)
}else{
setMeasuredDimension(suggestWidth,suggestHeight)
}
}
创建实体类:(扇形的颜色和所占的值)
class PieBean {
var picColor: Int? = null
var percent: Float? = null
}
2.设置数据
private var mDataList: MutableList<PieBean>? = null
/*
* 设置数据
* */
fun setPieBean(bean: PieBean) {
mDataList!!.add(bean)
invalidate()
}
通过对外提供方法,设置数据源
3.绘制图形(核心)
//用于记录每次开始绘制的起始角度
var startAngle = 0f
//每个扇形对应的折线路径的角度(起始角度 + 扇形所占角度的二分之一)
var pathPercent = 0f
for (index in 0 until mDataList!!.size){
mLinePath!!.reset()
val float = mDataList!![index].percent!!
val color = mDataList!![index].picColor!!
val percent = float.div(100) * 360
//设置扇形、文字、折线的颜色一致
mPathPaint!!.color = color
mPercentPaint!!.color = color
mTextPaint!!.color = color
pathPercent = startAngle + percent.div(2)
//绘制文字和path
if(mShouldShowText){
if(pathPercent >= 0 && pathPercent < 90){
drawPathAndText(float,pathPercent,0f,1,1,0,false,canvas!!)
}else if(pathPercent >= 90 && pathPercent < 180){
drawPathAndText(float,pathPercent,90f,-1,1,1,true,canvas!!)
}else if(pathPercent >= 180 && pathPercent < 270){
drawPathAndText(float,pathPercent,180f,-1,-1,1,false,canvas!!)
}else{
drawPathAndText(float,pathPercent,270f,1,-1,0,true,canvas!!)
}
}
//绘制圆弧
if(mTouchDegree > startAngle && mTouchDegree < (startAngle + percent)){
mRectF = RectF(mCenterX - mRadius - mRadius.div(10),mCenterY - mRadius - mRadius.div(10),
mCenterX + mRadius + mRadius.div(10) ,mCenterY + mRadius + mRadius.div(10))
canvas!!.drawArc(mRectF,startAngle,mProgressList!![index],true,mPercentPaint)
}else{
mRectF = RectF(mCenterX - mRadius,mCenterY - mRadius,mCenterX + mRadius,mCenterY + mRadius)
canvas!!.drawArc(mRectF,startAngle,mProgressList!![index],true,mPercentPaint)
}
startAngle += percent
}
先看一下绘制圆弧的代码
//绘制圆弧
if(mTouchDegree > startAngle && mTouchDegree < (startAngle + percent)){
mRectF = RectF(mCenterX - mRadius - mRadius.div(10),mCenterY - mRadius - mRadius.div(10),
mCenterX + mRadius + mRadius.div(10) ,mCenterY + mRadius + mRadius.div(10))
canvas!!.drawArc(mRectF,startAngle,mProgressList!![index],true,mPercentPaint)
}else{
mRectF = RectF(mCenterX - mRadius,mCenterY - mRadius,mCenterX + mRadius,mCenterY + mRadius)
canvas!!.drawArc(mRectF,startAngle,mProgressList!![index],true,mPercentPaint)
}
这里比较简单,mTouchDegree 是后面触摸事件所得到的一个参数,手指按下的点距离x轴正坐标的角度,如图所示
被手指点击的扇形变大,其他地方正常显示
再来看一下绘制折线和文字的代码,我将四个象限的代码整合了一下,复用写成了一个方法:
/*
* 绘制圆外的path和文字
* countDegree 需要减掉的度数
* xCoefficient x坐标的系数
* yCoefficient y坐标的系数
* pathPercent 路径需要绘制的起点的角度
* float 当前绘制的圆弧角度
* textOffset 绘制的文字是否需要偏移
* quadrant 是否是一三象限
* */
private fun drawPathAndText(float: Float,pathPercent:Float,countDegree: Float,
xCoefficient: Int,yCoefficient: Int,textOffset: Int,
quadrant: Boolean,canvas: Canvas){
val sin = Math.sin(Math.toRadians((pathPercent - countDegree).toDouble()))
val cos = Math.cos(Math.toRadians((pathPercent - countDegree).toDouble()))
val pathX = (mCenterX + xCoefficient * mRadius * (if (quadrant) sin else cos )).toFloat()
val pathY = (mCenterY + yCoefficient * mRadius * (if (quadrant) cos else sin)).toFloat()
val nextX = (mCenterX + xCoefficient * (mRadius.div(2) * 3) * (if (quadrant) sin else cos )).toFloat()
val nextY = (mCenterY + yCoefficient * (mRadius.div(2) * 3) * (if (quadrant) cos else sin)).toFloat()
val endX = (mCenterX + xCoefficient * (mRadius.div(2) * 3) * (if(quadrant) sin else cos) + xCoefficient * mRadius.div(4)).toFloat()
mLinePath!!.moveTo(pathX,pathY)
mLinePath!!.lineTo(nextX,nextY)
mLinePath!!.lineTo(endX,nextY)
canvas.drawPath(mLinePath,mPathPaint)
canvas.drawText(float.toString().plus("%"),endX - textOffset * mTextPaint!!.measureText(float.toString().plus("%")),nextY,mTextPaint)
}
如图所示,P点的位置(在数学上,这个位置是第四象限),在x轴上为 centerX + mRadius * cos(a)
在y轴上的坐标是 centerY + mRadius * sin(a)
同时需要注意一点的是 一三象限和二四象限的P点坐标求值是有略微区别的,例如一三象限的P的x坐标中乘的不是cos(a),而是sin(a),所以才会有下面的判断
参数 quadrant:是否是一三象限
mRadius * (if (quadrant) sin else cos ))
xCoefficient yCoefficient 的正负关系,我是根据这里的实际情况得出的,例如图中的第四象限,P点的坐标比圆点的都要大,所有这两个参数都是正的
关系如下:
象限 | xCoefficient | yCoefficient |
---|---|---|
四 | 正 | 正 |
三 | 负 | 正 |
二 | 负 | 负 |
一 | 正 | 负 |
文字的绘制是在折线之后,如果是在右半部分,文字就是在折线的右边,如果是在左半部分,文字在折线的左边,这时候只需要
textOffset * mTextPaint!!.measureText(float.toString().plus("%"))
来控制就好了,textOffset 的值(右边为0,左边为1)
4.动画实现
fun startAnim(){
mProgressList = FloatArray(mDataList!!.size)
val animatorSet = AnimatorSet()
val list = mutableListOf<ValueAnimator>()
for (index in 0 until mDataList!!.size){
val float = mDataList!![index].percent!!
val percent = float.div(100) * 360
val animator = ValueAnimator.ofFloat(percent)
animator.duration = 2500
animator.addUpdateListener {
mProgressList!![index] = it.animatedValue as Float
invalidate()
}
list.add(animator)
}
for (index in 0 until mDataList!!.size - 1){
//动画同时进行
animatorSet.play(list[index]).with(list[index + 1])
}
animatorSet.start()
}
和onDraw的for循环中:
canvas!!.drawArc(mRectF,startAngle,mProgressList!![index],true,mPercentPaint)
因为每个扇形都是有各自的动画,这里用了AnimatorSet,控制动画同时进行,不同的扇形有各自的进度,这里用了一个浮点数组来获取实时的动画进度
5.Touch事件
MotionEvent.ACTION_DOWN -> {
if(judgeTouchRange(event.x,event.y)){
//获取触摸点的角度
mTouchDegree = getRotationBetweenLines(event.x,event.y)
var temp = 0f
for (index in 0 until mDataList!!.size){
val float = mDataList!![index].percent!!
val percent = float.div(100) * 360
if(mTouchDegree > temp && mTouchDegree <= temp + percent){
mPieClickListener!!.onPieClick(index,float)
}
temp += percent
}
invalidate()
}
}
mTouchDegree 代表的含义在第三步绘制图形中已经有所提及
/**
* 获取两条线的夹角
*/
private fun getRotationBetweenLines(xInView: Float, yInView: Float): Float {
val centerX = width.div(2).toFloat()
val centerY = height.div(2).toFloat()
var rotation = 0.0
val k1 = (centerY - centerY).toDouble() / (centerX * 2 - centerX)
val k2 = (yInView - centerY).toDouble() / (xInView - centerX)
val tmpDegree = Math.atan(Math.abs(k1 - k2) / (1 + k1 * k2)) / Math.PI * 180
if (xInView > centerX && yInView < centerY) { //第一象限
rotation = tmpDegree + 270
} else if (xInView > centerX && yInView > centerY) {//第四象限
rotation = tmpDegree
} else if (xInView < centerX && yInView > centerY) { //第三象限
rotation = tmpDegree + 90
} else if (xInView < centerX && yInView < centerY) { //第二象限
rotation = tmpDegree + 180
} else if (xInView == centerX && yInView < centerY) {
rotation = 0.0
} else if (xInView == centerX && yInView > centerY) {
rotation = 180.0
}
return rotation.toFloat()
}
mTouchDegree 获取到后,通知View重绘就好了,在onDraw方法中已经处理了
同时在这里提供了一个对外的接口,用于实现点击事件
interface PieClickListener{
fun onPieClick(position: Int,percent: Float)
}
当用户点击某个扇形的时候,我们可以获取到它的位置和所占的百分比。
6.最后
至此,这个饼状图大体上就完成了,其他细节,完整的代码里都有体现。
如果有兴趣,可以移步这里:PieChartView,如果能顺手点个Star,也是极好的