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: 饼状图绘制的主要内容,如果未能解决你的问题,请参考以下文章