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


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

    系统mac

    android studio: 4.1.3

    kotlin version1.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(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)
        }
    
    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(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)
    }
    
    7AA8A214410EB70F5B239C6653800DBD

    完整代码

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

  • 相关阅读:
    多线程私有数据pthread_key_create
    codeforces:C. Set Construction【构造 + 入度观察】
    element + vue3,级联选择器实现省市区
    老电脑不能装纯净版windows
    Mysql和Elasticsearch的数据同步
    IPv5是什么意思?到底有没有IPv5?
    Arduino从零开始(0)——介绍与点亮LED
    iMazing2024最新版iOS设备管理软件
    Python数据结构:数字与字符串
    深度解读昇腾CANN多流并行技术,提高硬件资源利用率
  • 原文地址:https://blog.csdn.net/weixin_44819566/article/details/127117658