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

完整代码

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

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

s10g

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值