android自定义View: 绘制图表
Posted 史大拿
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了android自定义View: 绘制图表相关的知识,希望对你有一定的参考价值。
本系列自定义View全部采用kt
系统:mac
android studio: 4.1.3
kotlin version: 1.5.0
gradle: gradle-6.5-bin.zip
本篇内容: 从0到1绘制一个可控制的图表!
本篇效果:
效果1 | 效果2 | 效果3 |
---|---|---|
绘制表格
假设现在要绘制 5*5 的表格,那么首先需要做什么事情呢?
那么就必须计算:
- 每一格的宽 (eachWidth) = View.width / 5
- 每一格的高(eachHeight) = View.height / 5
来看看代码:
class E1BlogView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr)
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
// 水平个数
private val horizontalCount = 5
// 垂直个数
private val verticalCount = 5
private val data = arrayListOf<E1LocationBean>()
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int)
super.onSizeChanged(w, h, oldw, oldh)
if (data.size == 0)
data.clear()
// 每一格的宽
val eachWidth = w / verticalCount
// 每一格的高
val eachHeight = h / horizontalCount
(0 until 5).forEachIndexed index, value ->
// 保存每一格的宽高
// tips:这里 *1f 是为了 Int -> Float
data.add(
E1LocationBean(
index * eachWidth * 1f,
index * eachHeight * 1f,
value
)
)
override fun onDraw(canvas: Canvas)
// 绘制网格
drawGrid(canvas)
private fun drawGrid(canvas: Canvas)
data.forEach
// 绘制垂直线
canvas.drawLine(
it.x,
0f,
it.x,
height * 1f,
paint
)
// 绘制平行线
canvas.drawLine(
0f,
it.y,
width * 1f,
it.y,
paint
)
E1LocationBean.kt
data class E1LocationBean(val x: Float, val y: Float,val number:Int)
来看看现在效果:
咋们吧画布缩小一点,看看效果:
可以看出,最右面和最下面缺少两条线,那么来绘制上
private fun drawGrid(canvas: Canvas)
data.forEach ...
// 绘制右侧线
canvas.drawLine(
width * 1f,
0f,
width * 1f,
height * 1f,
paint
)
// 绘制底部线
canvas.drawLine(
0f,
height * 1f,
width * 1f,
height * 1f,
paint
)
效果:
绘制文字
首先我们需要了解,文字需要绘制到什么位置:
这里需要注意的是:
画布是经过缩小的,所以宽和高并不是屏幕的宽和高
canvas.scale(0.8f, 0.8f, width / 2f, height / 2f) // 屏幕中心点缩小
那么需要文字绘制的坐标为:
- x = 负文字的宽度
- y = 每一格的高度 - 文字的高度 (绘制文字是根据baseline线来绘制的,所以需要减掉)
来看看代码:
data.forEachIndexed index, value ->
// 如果number > 0 并且当前不是最后一行
if (index != horizontalCount)
val text = "$index"
// 计算文字宽高
val rect = Rect()
paint.getTextBounds(text, 0, text.length, rect)
val textWidth = rect.width()
val textHeight = rect.height()
val x = -textWidth - 5.dp // 不让他贴的太近,在稍微往左一点
val y = value.y - paint.fontMetrics.top
canvas.drawText(
text,
x,
y - textHeight,
paint
)
文字绘制出来了,但是有几个问题:
- 文字应该是 4,3,2,1,0 而不是0,1,2,3,4
- 在实际中,真正的数据也不可能是01234
现在假设数据为:
private val originList = listOf(
70, 80, 100, 222, 60
)
那么我们需要将它分为5格
步骤分析:
- 找出数组中的最大值
- 最大值 / 5 就算出了每一格的数字
- 最大值 - 每一格的数据 = 翻转数据
来看看代码:
private val originList = listOf(
70, 80, 100, 222, 60
)
// 水平个数
private val horizontalCount = 5
private fun drawText(canvas: Canvas)
paint.textSize = 16.dp
// 获取最大值
val max = originList.maxOrNull()!!
// 计算每一格的值
val eachNumber = max / horizontalCount
data.forEachIndexed index, value ->
// 最大值 - 当前值 = "翻转"数据
val number = max - eachNumber * index
// 如果number > 0 并且当前不是最后一行
val text = "$number"
// 绘制文字
canvas.drawText(...)
绘制点
还是以上面的数据来举例:
private val originList = listOf(70, 80, 100, 222, 60)
那么每一格的x点就是每一格方格的位置
那么y轴怎么算呢?
现在知道
- 最大值(max)为 222
- view的高度为height
那么每一小格的高度也就知道, max / height, 就可以算出每一格的坐标:
来看一眼代码:
private fun drawPoint(canvas: Canvas)
paint.strokeWidth = 10.dp
// 数组最大值
val max = originList.maxOrNull()!!
// 每一格的宽高
val eachHeight = height.toFloat() / max
val eachWidth = width.toFloat() / verticalCount
originList.forEachIndexed index, value ->
val x = eachWidth * index
val y = height - eachHeight * value // 取反
canvas.drawPoint(x, y, paint)
绘制线
绘制线比较简单,知道了每一个点,直接连接起来即可!
private val path = Path()
private fun drawPoint(canvas: Canvas)
paint.strokeWidth = 10.dp
// 数组最大值
val max = originList.maxOrNull()!!
// 每一格的宽高
val eachHeight = height.toFloat() / max
val eachWidth = width.toFloat() / verticalCount
originList.forEachIndexed index, value ->
val x = eachWidth * index
val y = height - eachHeight * value // 取反
// 绘制点
canvas.drawPoint(x, y, paint)
// 当index = 0,将画笔移动过去,
if (index == 0)
path.moveTo(x, y)
else // 然后在连起来
path.lineTo(x, y)
paint.style = Paint.Style.STROKE
paint.strokeWidth = 2.dp
// 绘制线
canvas.drawPath(path, paint)
path.reset()
那么吧数据变多,再来测试一下:
private val originList = listOf(
70, 80, 100, 222, 60,
70, 80, 100, 222, 60,
)
可以看出,又有问题了,线画出区域外了,那么只需要保留表格内的东西即可
裁剪
只需要将表格外的东西裁剪掉即可
上面我们说过,表格是通过缩放来绘制的
如图:
所以只需要裁剪view的宽 和 高即可
private fun drawPoint(canvas: Canvas)
// 数组最大值
val max = originList.maxOrNull()!!
// 每一格的宽高
val eachHeight = height.toFloat() / max
val eachWidth = width.toFloat() / verticalCount
originList.forEachIndexed index, value ->
// 绘制点
canvas.drawPoint(x, y, paint)
if (index == 0)
path.moveTo(x, y)
else
path.lineTo(x, y)
// 裁剪表格, 只保留表格内的数据
canvas.clipRect(0, 0, width, height)
// 绘制线
canvas.drawPath(path, paint)
paint.reset()
滑动事件处理
因为我们只可以左右滑动,所以只需要操作X轴即可
记录滑动距离:
private var offsetX = 0f
private var downX = 0f
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean
when (event.action)
MotionEvent.ACTION_DOWN ->
// 记录按下位置
downX = event.x
MotionEvent.ACTION_MOVE ->
// 计算偏移量
offsetX = event.x - downX
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL ->
invalidate()
return true
计算完成之后,offsetX 就是偏移的量,那么这个offsetX在什么地方用呢?
我们知道,点和连接线 ,都是通过 originList中的x来计算的
所以只需要将offsetX 添加到绘制点坐标的x上即可
private fun drawPoint(canvas: Canvas)
originList.forEachIndexed index, value ->
val x = eachWidth * index + offsetX // 添加到这里!
val y = height - eachHeight * value
// 绘制点
canvas.drawPoint(x, y, paint)
/// ...
// 裁剪表格, 只保留表格内的数据
canvas.clipRect(0, 0, width, height)
// 绘制线
canvas.drawPath(path, paint)
path.reset()
来看看效果:
现在有3个问题
- 滑动距离计算不对, offsetX应该是每一次的偏移量
- 连接线和点当移动到第0个位置,和最后一个位置的时候就不可以移动了
- 点不受canvas.clipRect() 约束, 所以导致可以画出表格外
第一个问题
滑动距离计算不对, offsetX应该是每一次的偏移量
先来看现在的问题:
当第一次滑动的时候,当前滑动的距离 = move.x - down.x 这个是对的
但是当第二次滑动的时候,距离就不对了,还是move.x - down.x 就会导致一直滑动一块距离
所以当第二次滑动的时候,需要吧上一次的滑动过的距离加上
如图:
当前的偏移量 = move.x - down.x +上一次的偏移量
来看看代码:
private var offsetX = 0f
private var downX = 0f
private var originX = 0f
override fun onTouchEvent(event: MotionEvent): Boolean
when (event.action)
MotionEvent.ACTION_DOWN ->
// 按下的距离
downX = event.x
// 记录上一次的滑动距离
originX = offsetX
MotionEvent.ACTION_MOVE ->
// 当前偏移位置 = 当前位置 - 按压位置 + 上一次偏移量
offsetX = event.x - downX + originX
invalidate()
return true
问题二
来解决第二个问题:
连接线和点当移动到第0个位置,和最后一个位置的时候就不可以移动了
这个问题比较简单,只需要控制offsetX即可
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean
when (event.action)
MotionEvent.ACTION_DOWN ->
downX = event.x
originX = offsetX
MotionEvent.ACTION_MOVE ->
// 当前偏移位置 = 当前位置 - 按压位置
offsetX = event.x - downX + originX
// 禁止滑出表格外
if (offsetX > 0)
offsetX = 0f
// 禁止滑出表格外
if (offsetX <= -(data.last().x - width))
offsetX = -(data.last().x - width)
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL ->
invalidate()
return true
问题三
点不受canvas.clipRect() 约束, 所以导致可以画出表格外
这个问题也比较简单,和问题二处理方式一样
在绘制过程中,
只需要控制x点在屏幕内即可
private fun drawPoint(canvas: Canvas)
originList.forEachIndexed index, value ->
val x = eachWidth * index + offsetX
val y = height - eachHeight * value
// TODO 当前x在屏幕内才绘制
if (x >= 0 && x <= width)
canvas.drawPoint(x, y, paint)
....
// 裁剪表格, 只保留表格内的数据
canvas.clipRect(0, 0, width, height)
canvas.drawPath(path, paint)
path.reset()
现在基本效果已经完成了, 但是滑动起来比较僵硬,
毕竟是第一篇入门绘制,先整体有个思路,如果你想家fing的话,可以 参考这篇
在今天完成的效果中,在滑动过程中,还需要将点变成具体的值
每一个点的坐标都可以获取到,值也可以获取到,只需要判断是否滑动中判断一下即可,就不粘代码了 !
添加动画
添加动画之前,首先需要了解 PathMeasure(路径测量) 和 PathEffect(路径效应)
PathMeasure
我们知道在View中有onMeasure来测量View的宽和高
那么PathMeasure() 看名字也知道,是用来测量Path的
PathMeasure 可以测量很多东西,例如Path的长度
本篇只用到了长度测量,其他详细参数看这里
那么怎么使用?
private val pathMeasure = PathMeasure()
pathMeasure.setPath(path, false) // false表示不闭合
val len = pathMeasure.length // 获取路径的长度
PathEffect
PathEffect 其实就是对paint的一些“变换”操作, 使用比较简单,如果感兴趣可以下载底部完整代码查看
那么先来将图表中的实线改为虚线尝尝鲜
private fun drawPoint(canvas: Canvas)
.....
// 定义虚线
val dashPathEffect = DashPathEffect(floatArrayOf(100f, 20f, 50f, 20f), 0f)
// 设置虚线
paint.pathEffect = dashPathEffect
canvas.drawPath(path, paint)
// 使用完置null
paint.pathEffect = null
path.reset()
DashPathEffect参数:
@param1 : 先画100f实线 -> 在画20f虚线 -> 在画50f实线 -> 最后20f实线 以此类推,画完为止
@param2 : 一个偏移量,如果只是画虚线填任何数都不起作用, 我的理解是主要来配合动画
设置动画
动画还是用我们的老朋友属性动画
private fun startLineAnimator()
val animator = ObjectAnimator.ofFloat(1f, 0f)
animator.duration = 2000
// 计算路径总长度
val length = pathMeasure.length
animator.addUpdateListener
// 当前进度
val fraction = it.animatedValue as Float
// 画实线
val dashPathEffect1 = DashPathEffect(floatArrayOf(length, length), length * fraction)
paint.pathEffect = dashPathEffect1
invalidate()
animator.start()
开启动画
override fun onDraw(canvas: Canvas)
canvas.scale(0.8f, 0.8f, width / 2f, height / 2f)
// 绘制网格
drawGrid(canvas)
// 绘制文字
drawText(canvas)
// 绘制点和连接线
// 在这里记录连接线的长度 调用 pathMeasure.setPath(path, false)
drawPoint(canvas)
// 设置动画
if (!isFlag)
startLineAnimator()
isFlag = !isFlag
可以看出,虽然完成了,但是还是有问题
- 图表不需要动画 , 那么画连接线的时候,就需要一根单独的画笔来操作
- 代码太丑了
按照正常的逻辑,应该是在外面设置数据,在设置数据的同时计算出每一个点的坐标,然后开启动画 最后绘制每一个点
现在是数据写死了,所以就导致在绘制的过程中在计算每一个点,在测量path的距离,在开始动画
这样代码又丑,其他人用起来又难受,
为什么要这么写? 我坦白了,我是故意的
大家可以按照正常逻辑改一改代码~
设置单独画笔:
代码简单,就不看了,直接看效果
实心
实心也很简单,只需要按照path将 Paint#style设置为FILL即可
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bTC1u78U-1663652658409)(/Users/shizhenjiang/Library/Application%20Support/typora-user-images/image-20220920111701811.png)]
private fun drawPoint(canvas: Canvas)
originList.forEachIndexed index, value 以上是关于android自定义View: 绘制图表的主要内容,如果未能解决你的问题,请参考以下文章