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格

步骤分析:

  1. 找出数组中的最大值
  2. 最大值 / 5 就算出了每一格的数字
  3. 最大值 - 每一格的数据 = 翻转数据

来看看代码:

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

android自定义View: 绘制图表

自定义View_1_关于View,ViewGroup的测量和绘制流程

android 自定义view: 矩形图表

android 自定义view: 矩形图表

android 自定义view: 矩形图表

android基础-viewgroup的测量,布局,绘制