• 首页 首页 icon
  • 工具库 工具库 icon
    • IP查询 IP查询 icon
  • 内容库 内容库 icon
    • 快讯库 快讯库 icon
    • 精品库 精品库 icon
    • 问答库 问答库 icon
  • 更多 更多 icon
    • 服务条款 服务条款 icon

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

武飞扬头像
s10g
帮助2

本系列自定义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
}
学新通

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

那么只能旋转画布了

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)
}

学新通

完整代码

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

这篇好文章是转载于:学新通技术网

  • 版权申明: 本站部分内容来自互联网,仅供学习及演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,请提供相关证据及您的身份证明,我们将在收到邮件后48小时内删除。
  • 本站站名: 学新通技术网
  • 本文地址: /boutique/detail/tanhgcbkji
系列文章
更多 icon
同类精品
更多 icon
继续加载