android自定义View: 饼状图绘制

Posted 史大拿

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了android自定义View: 饼状图绘制相关的知识,希望对你有一定的参考价值。

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

系统mac

android studio: 4.1.3

kotlin version1.5.0

gradle: gradle-6.5-bin.zip

本篇效果:

画矩形

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

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

画扇形

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
        
    
                     

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

可以看出,是没问题的

测量

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

默认选中

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

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

放大来看看细节:

此时我们知道 AB = 20.dp

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

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

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

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

open var clickPosition = 2

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

绘制文字

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

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

那么每个文字的位置为:

@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线上的,所以需要重新计算文字的位置

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

事件处理(转起来)

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)

事件处理(点击选中)

思考:

在矩形 或者 是 圆的时候,可以通过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

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

来看看当前的效果:

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

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

文字面朝我

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

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

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

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

文字不需要旋转,只需要将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(left, top, right, bottom, 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)
    

扣内圆

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

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

入场动画

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

private var currentFraction = 0f

private val animator by lazy 
    val animator = ObjectAnimator.ofFloat(0f, 1f)
    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(left, top, right, bottom, startAngle, ration, true, paint)
    canvas.rotate(-offsetAngle, width / 2f, height / 2f)

完整代码

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

以上是关于android自定义View: 饼状图绘制的主要内容,如果未能解决你的问题,请参考以下文章

android自定义View: 饼状图绘制

python添加饼图扇形面积

Canvas---绘制饼状图

自定义饼状图

自定义饼状图

Android 自定义View:绘制轮盘扇形区并加入扇形区点击事件