一步步实现自定义View之饼状图

首先看一下效果图:
这里写图片描述

对于这个饼状图,我也没有说一开始就想好要做成什么样,只是单纯地想做一个。绘制圆弧部分很简单。但是只是画出几个圆弧肯定是不够的。于是我就又在外面加了一些文字。文字有了,我又想为何不在一开始的时候给它加个动画呢?于是就有了这个组合的动画。动画有了,我又想不如再加个点击事件吧。这样一个简单的饼状图就完成了。

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点的坐标比圆点的都要大,所有这两个参数都是正的
关系如下:

象限xCoefficientyCoefficient

文字的绘制是在折线之后,如果是在右半部分,文字就是在折线的右边,如果是在左半部分,文字在折线的左边,这时候只需要

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,也是极好的

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值