android 自定义View: 饼状图绘制(四)

460 阅读6分钟

本系列自定义View全部采用kt

系统: mac

android studio: 4.1.3

kotlin version: 1.5.0

gradle: gradle-6.5-bin.zip

本篇效果:

B5CB60521DF1A49EB77E6937958C7E98

画矩形

在绘制饼状图之前,首先要绘制扇形, 想到扇形的api可能用的不多,所以先来绘制一个矩形练练手

image-20220929093816798

代码比较简单,就不多说了

画扇形

image-20220929094030822

Canvas#drawArc入参介绍:

  • Left,top,right,bottom: 矩形的位置
  • startAngle: 开始角度
  • sweepAngle: 扫过的角度
  • userCenter: 是否连接中点
  • paint: 画笔

这里比较不容理解的就是userCenter参数,

  • userCenter = true: 连接到矩形的中心位置
  • userCenter = false: 连接开始位置 和 结束位置

可以通过辅助的矩形多尝试一下QaQ

造数据,画扇形

 private val data = listOf(
     Triple(Color.RED, 1f, "红色"),
     Triple(Color.WHITE, 2f, "白色"),
     Triple(Color.YELLOW, 3f, "黄色"),
     Triple(Color.GREEN, 1f, "绿色"),
 )
  • first = 颜色
  • second = 值
  • third = 文字

首先需要计算出每一份的占比,

每个扇形的占比 = 360f / (data.second的和)

 // 总数
 private val totalNumber: Float
     get() {
         return data.map { it.second }.fold(0f) { a, b -> a + b }
    }
 ​
 ​
 // 每一份的大小
 val each = 360f / totalNumber

那么扇形为:

  companion object {
         val RADIUS = 200.dp
    } 
 ​
 override fun onDraw(canvas: Canvas) {
         super.onDraw(canvas)
 ​
         // 居中显示
         val left = width / 2f - RADIUS / 2f
         val top = height / 2f - RADIUS / 2f
         val right = left + RADIUS
         val bottom = top + RADIUS
 ​
         // 每一份的大小
         val each = 360f / totalNumber
 ​
         // 开始位置
         var startAngle = 0f
         data.forEachIndexed { position, value ->
             // 求出每一份的占比
             val ration = each * value.second
             paint.color = value.first // 设置颜色
             canvas.drawArc(left, top, right, bottom, startAngle, ration, true, paint)
             startAngle += ration
        }
    }
                      

211DB31DF2A1F9BF0046F0989EC9FA55

再把数据随便调整一下再来测试一下:

image-20220929100545128

可以看出,是没问题的

测量

测量代码比较简单,直接来看看就行

image-20220929100819734

默认选中

假设我们现在是选中的2号,

我们需要吧2号往左上偏移一点,假设需要偏移20.dp

image-20220929101245887

放大来看看细节:

image-20220929103013781

此时我们知道 AB = 20.dp

那么我们只需要求出角ABC即可

很显然,角ABC = 划过的角度 / 2f

此时开始滑动的角度 = 紫色BC

那么他的偏移量 = 开始滑动的角度(startAngle) + 划过的角度 / 2f

image-20220929104754833

 open var clickPosition = 2

可以看出, 此时选中的扇形,超出view了,所以还需要修改一下测量

image-20220929110416049

绘制文字

绘制文字前首先要确定文字的位置

我们希望文字绘制到每个扇形的正中间

那么每个文字的位置为:

 @param startAngle:开始角度
 @param sweepAngle:划过的角度
 private fun drawText(canvas: Canvas, startAngle: Float, sweepAngle: Float, position: Int) {
 ​
         // 当前角度 = 开始角度 + 划过角度的一半
         val ration = startAngle + sweepAngle / 2f
         // 当前文字半径 = 半径一半的70%
         val radius = (RADIUS / 2f) * 0.7f
 ​
         val dx =
             radius * cos(Math.toRadians(ration * 1.0)).toFloat() + width / 2f
         val dy =
             radius * sin(Math.toRadians(ration * 1.0)).toFloat() + height / 2f
 ​
 ​
         paint.color = Color.BLACK
         canvas.drawCircle(dx, dy, 2.dp, paint) // 辅助圆
 ​
 ​
         paint.textSize = 16.dp
 ​
         val text = "${data[position].third}$position"
         val textWidth = paint.measureText(text) // 文字宽度
         val textHeight = paint.descent() + paint.ascent() // 文字高度
 //
         val textX = dx - (textWidth / 2f)
         val textY = dy - (textHeight / 2f)
 ​
         canvas.drawText(text, 0, text.length, textX, textY, paint)
    }
 ​

因为绘制文字是在baseline线上的,所以需要重新计算文字的位置

代码和 上边刚提到的默认选中类似, 只是半径不同而已.

image-20220929130859613

事件处理(转起来)

 private var offsetAngle = 0f
 private var downAngle = 0f
 private var originAngle = 0f
 ​
 @SuppressLint("ClickableViewAccessibility")
 override fun onTouchEvent(event: MotionEvent)Boolean {
     when (event.action) {
         MotionEvent.ACTION_DOWN -> {
             downAngle = (PointF(event.x, event.y)).angle(PointF(width / 2f, height / 2f))
             originAngle = offsetAngle
        }
 ​
         MotionEvent.ACTION_MOVE -> {
             parent.requestDisallowInterceptTouchEvent(true)
 ​
             offsetAngle = (PointF(event.x, event.y)).angle(
                 PointF(
                     width / 2f,
                     height / 2f
                )
            ) - downAngle + originAngle
 ​
             invalidate()
        }
 ​
         MotionEvent.ACTION_UP -> {
 ​
        }
 ​
    }
     return true
 }

这段代码和 上一篇旋转一模一样, 这就就不多说了

不一样的是,在上一篇中,只需要吧offsetAngle设置给角度即可

但是这一篇饼状图好像没有角度

那么只能旋转画布了

 override fun onDraw(canvas: Canvas) {
     super.onDraw(canvas)
 ​
     canvas.rotate(offsetAngle, width / 2f, height / 2f)
 }

9EE376AF6A025042662AEE4453974B63

事件处理(点击选中)

思考:

在矩形 或者 是 圆的时候,可以通过x,y坐标去计算是否选中

但是扇形的话,如果判断是否选中呢?

其实很简单,在抬起的时候,我们可以获取到抬起时候,距离中心点的位置

那么,我们只需要判断现在抬起的角度 和扇形的角度做比较即可

 @SuppressLint("ClickableViewAccessibility")
 override fun onTouchEvent(event: MotionEvent)Boolean {
     when (event.action) {
         MotionEvent.ACTION_DOWN -> {
            ........
        }
 ​
         MotionEvent.ACTION_MOVE -> {
            .... 
        }
         MotionEvent.ACTION_UP -> {
 ​
             // 当前角度
             var angle =
                (PointF(event.x, event.y)).angle2(PointF(width / 2f, height / 2f))
 ​
             // 当前偏移量
             angle = getNormalizedAngle(angle)
 ​
             // 当前滑动距离
             val offset = getNormalizedAngle(offsetAngle)
 ​
             // 位移后的距离
             val a = getNormalizedAngle(angle - offset)
 ​
             var startAngle = 0f
             data.forEachIndexed { index, value ->
                 // 每一格的占比
                 val ration = each * value.second
 ​
                 val start = startAngle
                 val end = startAngle + ration
 ​
                 if (a in start..end) {
                     // 如果当前选中的重复按下,那么就让当前选中的关闭
                     clickPosition = if (clickPosition == index && clickPosition != -1) {
                         -1
                    } else {
                         // 否则重新赋值
                         index
                    }
                     invalidate()
                     return true
                }
                 startAngle = end
            }
        }
    }
     invalidate()
     return true
 }
 ​
 open fun getNormalizedAngle(angle: Float)Float {
   var a = angle
   while (a < 0f) a += 360f
   return a % 360f
 }

这里有一个小坑,害得我弄了一下午,最后还没弄出来,还是看 MPAndroidChart源码,看了10分钟就恍然大悟...

假设1

当前滑动的位置为 359 , 那么他可能计算出的结果为 -1 ,

一圈360度, -1 和 359其实是同一个位置,但是一旦用不同的方式表达出来,结果就会不一样

假设2

当前滑动了3圈 + 20度,那么他滑动的偏移量 为 3 * 360 + 20 ,然而扇形就没有超过360度的这也会导致出问题

假设3

还是滑动了3圈 + 20度,只不过是逆时针滑动, 算出来的结果会是负数, 然而扇形更没有<0 的角度

所以必须通过:

 open fun getNormalizedAngle(angle: Float)Float {
   var a = angle
   while (a < 0f) a += 360f
   return a % 360f
 }

来保证数据一定是在 大于0,并且 小于360

这段文字比较抽象,如果你看到肯定不知道我在说什么,所以建议你按照你的思路写一下,就会看出问题!

来看看当前的效果:

D7AF69B2F426CE83EE590F60CA448EAA

可以看出,现在是可以点击了,但是在旋转过程中,文字也跟随旋转了,

导致我就得歪头看字,效果还不太行.

文字面朝我

首先要捋清楚这是什么问题导致的,需要改什么,怎么改

很明显,这是旋转画布导致的,

首先不能纯粹的旋转画布,

只需要旋转画布上的扇形,

文字不需要旋转,只需要将offsetAngle设置给角度即可

只旋转某个东西,只需要将画布保存恢复即可. 》 __<

只旋转扇形:

 override fun onDraw(canvas: Canvas) {
     super.onDraw(canvas)
 ​
     // canvas.rotate(offsetAngle, width / 2f, height / 2f)
 ​
    .... 
     data.forEachIndexed { position, value ->
 ​
         // 每一格的占比
         val isSave = position == clickPosition % data.size
         if (isSave) {
             canvas.save()
 ​
             // 旋转
             canvas.rotate(offsetAngle, width / 2f, height / 2f)
             val angle = startAngle.toDouble() + ration / 2f
 ​
             val dx =
                 DISTANCE * cos(Math.toRadians(angle)).toFloat()
             val dy =
                 DISTANCE * sin(Math.toRadians(angle)).toFloat()
             canvas.translate(dx, dy)
 ​
             // 在转回来
             canvas.rotate(-offsetAngle, width / 2f, height / 2f)
 ​
        }
         paint.color = value.first
 ​
         canvas.withSave {
             canvas.rotate(offsetAngle, width / 2f, height / 2f)
             // 绘制扇形
             canvas.drawArc(lefttoprightbottom, startAngle, ration, true, paint)
             canvas.rotate(-offsetAngle, width / 2f, height / 2f)
        }
 ​
 ​
         // 绘制文字
         drawText(canvas, startAngle, ration, position)
 ​
         startAngle += ration
 ​
         if (isSave) {
             canvas.restore()
        }
    }
 }

将角度设置给文字:

   private fun drawText(canvas: Canvas, startAngle: Float, sweepAngle: Float, position: Int) {
 ​
         // 当前角度 = 开始角度 + 划过角度的一半
         val ration = startAngle + sweepAngle / 2f + offsetAngle
         // 当前文字半径 = 半径一半的70%
         val radius = (RADIUS / 2f) * 0.7f
 ​
        ...
 ​
         canvas.drawText(text, 0, text.length, textX, textY, paint)
    }

F832B704F78009AF4CF5FE6B5B4E8526

扣内圆

我看好多饼状图都是空心的,咋们也来实现一下

 private val path: Path by lazy {
     Path().also {
         it.addCircle(width / 2f, height / 2f, RADIUS / 6f, Path.Direction.CCW)
    }
 }
 ​
 /*
  * 作者:史大拿
  * 创建时间: 9/29/22 3:20 PM
  * TODO 扣内圆
  */
 private fun drawClipCircle(canvas: Canvas) {
     // 需要android版本 >= api26 (8.0)
     canvas.clipOutPath(path)
 }

扣内圆很简单,我是用的clipOutPath, 需要注意的是这个版本必须 >= 26

E71A8EDCAE56F3D05FEF227257E7B877

入场动画

入场动画也很简单,这段代码写了无数次了,

 private var currentFraction = 0f
 ​
 private val animator by lazy {
     val animator = ObjectAnimator.ofFloat(0f1f)
     animator.duration = 2000
     animator.addUpdateListener {
         currentFraction = it.animatedValue as Float
         invalidate()
    }
     animator
 }
 ​
 init {
     // 开启动画
     animator.start()
 }

currentFraction 会在view创建的时候2秒内从0变到1

那么只需要在绘制扇形的时候,赋值给startAngle即可

 ...
 ​
 canvas.withSave {
     canvas.rotate(offsetAngle, width / 2f, height / 2f)
  
     startAngle *= currentFraction
    // 绘制扇形
     canvas.drawArc(lefttoprightbottom, startAngle, ration, true, paint)
     canvas.rotate(-offsetAngle, width / 2f, height / 2f)
 }

7AA8A214410EB70F5B239C6653800DBD

完整代码

原创不易,您的点赞就是对我最大的帮助!


\