• 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
    7B75747752FF235B730EC70957982F6C8DC6BBD616750E2520007034B8C2FB537AA4A7915D36ADBC46E1D46AC78356FE

    绘制表格

    假设现在要绘制 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)
    

    来看看现在效果:

    7703A0BF450BE966C7786D04F5478FCC

    咋们吧画布缩小一点,看看效果:

    4C2CADFA591F0D96066F708545EB504C

    可以看出,最右面和最下面缺少两条线,那么来绘制上

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

    效果:

    D453FA1B1832B43F65A852972014676C

    绘制文字

    首先我们需要了解,文字需要绘制到什么位置:

    image-20220919170506058

    这里需要注意的是:

    画布是经过缩小的,所以宽和高并不是屏幕的宽和高

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

    文字绘制出来了,但是有几个问题:

    • 文字应该是 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(...)
        }
    }
    
    C662623D88475921A343F1CDB51F5F4B

    绘制点

    还是以上面的数据来举例:

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

    绘制线

    绘制线比较简单,知道了每一个点,直接连接起来即可!

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

    那么吧数据变多,再来测试一下:

    private val originList = listOf(
        70, 80, 100, 222, 60,
        70, 80, 100, 222, 60,
    )
    
    image-20220919193003811

    可以看出,又有问题了,线画出区域外了,那么只需要保留表格内的东西即可

    裁剪

    只需要将表格外的东西裁剪掉即可

    上面我们说过,表格是通过缩放来绘制的

    如图:

    image-20220919170506058

    所以只需要裁剪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()
        }
    
    C61BFB6F274C64F4FE5A935C3CADAFBD

    滑动事件处理

    因为我们只可以左右滑动,所以只需要操作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()
    }
    

    来看看效果:

    AEBAFA28E1A0584176920E8353F00759

    现在有3个问题

    • 滑动距离计算不对, offsetX应该是每一次的偏移量
    • 连接线和点当移动到第0个位置,和最后一个位置的时候就不可以移动了
    • 点不受canvas.clipRect() 约束, 所以导致可以画出表格外

    第一个问题

    滑动距离计算不对, offsetX应该是每一次的偏移量

    先来看现在的问题:

    image-20220920094027536

    当第一次滑动的时候,当前滑动的距离 = move.x - down.x 这个是对的

    但是当第二次滑动的时候,距离就不对了,还是move.x - down.x 就会导致一直滑动一块距离

    所以当第二次滑动的时候,需要吧上一次的滑动过的距离加上

    如图:

    image-20220920094234921

    当前的偏移量 = 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
    }
    
    230AE034D4948C33F7779B0C43419F9B

    问题二

    来解决第二个问题:

    连接线和点当移动到第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
    }
    
    39A5EA84768B2524266B5570A0186322

    问题三

    点不受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()
    }
    
    5BDD8B7C5210DD5E13E59A791796D6DA

    现在基本效果已经完成了, 但是滑动起来比较僵硬,

    毕竟是第一篇入门绘制,先整体有个思路,如果你想家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的一些“变换”操作, 使用比较简单,如果感兴趣可以下载底部完整代码查看

    97B6E17FAE076620EA1FBECF8DC8E531

    那么先来将图表中的实线改为虚线尝尝鲜

    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()
    }
    
    290F2F7EC26067792E7A64EB30D897E7

    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
            }
        }
    
    1DE15F2A2F6A08A3241D00D0CA541DC3

    可以看出,虽然完成了,但是还是有问题

    • 图表不需要动画 , 那么画连接线的时候,就需要一根单独的画笔来操作
    • 代码太丑了

    按照正常的逻辑,应该是在外面设置数据,在设置数据的同时计算出每一个点的坐标,然后开启动画 最后绘制每一个点

    现在是数据写死了,所以就导致在绘制的过程中在计算每一个点,在测量path的距离,在开始动画

    这样代码又丑,其他人用起来又难受,

    为什么要这么写? 我坦白了,我是故意的

    大家可以按照正常逻辑改一改代码~

    设置单独画笔:

    代码简单,就不看了,直接看效果

    E14926CC1765C52B2D217AA981C5C2FE

    实心

    实心也很简单,只需要按照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 ->
                val x = eachWidth * index + offsetX
                val y = height - eachHeight * value // 取反
    
                // 当前x在屏幕内才绘制
                if (x >= 0 && x <= width) {
                    canvas.drawPoint(x, y, paint)
                }
    						...
            }
      
      			// 最后一个点连接右下角
            path.lineTo(width.toFloat(), height.toFloat())
      			// 右下角连接左下角
            path.lineTo(0f, height.toFloat())
      			// 左下角连接第0个点
            path.close()
    
            // 设置path
            pathMeasure.setPath(path, false)
    				
    			  // 设置为实心
            linePaint.style = Paint.Style.FILL
            linePaint.strokeWidth = 2.dp
    
            // 裁剪表格, 只保留表格内的数据
            canvas.clipRect(0, 0, width, height)
            linePaint.color = Color.BLACK
    
            canvas.drawPath(path, linePaint)
    
            path.reset()
        }
    
    F9D089606D5D05B2E8652A3562017BA2

    设置渐变

    渐变同样和PathEffect一样,都是对paint的“变换”操作

    同样也可以下载底部完整代码:

    D90022E09D8BEE2FA73BA08D934B666F
    private fun drawPoint(canvas: Canvas) {
           .... 
    
            // 裁剪表格, 只保留表格内的数据
            canvas.clipRect(0, 0, width, height)
            // 设置渐变
            linePaint.shader = LinearGradient(
                width * 1f,
                height / 2f,
                width * 1f,
                height * 1f,
                Color.RED,
                Color.YELLOW,
                Shader.TileMode.CLAMP
            )
    
            // 设置连接线
            canvas.drawPath(path, linePaint)
            linePaint.shader = null
            path.reset()
        }
    
    AA1CDDE50937030C4EFECDD4C270D70B

    颜色也设置上去了,但是有一个小问题,颜色盖住了点

    这种情况就需要先绘制路径,在绘制点即可解决

    override fun onDraw(canvas: Canvas) {
    	 	    // 绘制网格
            drawGrid(canvas)
    
            // 绘制文字
            drawText(canvas)
    
            // 绘制线
            drawLine(canvas)
    
            // 绘制点和连接线
            drawPoint(canvas)
    }
    

    绘制线:

     private fun drawLine(canvas: Canvas) {
    
            val eachHeight = eachHeight
            val eachWidth = eachWidth
    
            originList.forEachIndexed { index, value ->
                val x = ((eachWidth * index) + offsetX)
                val y = (height - (eachHeight * value))
                // 绘制线
                if (index == 0) {
                    path.moveTo(x, y)
                } else {
                    path.lineTo(x, y)
                }
            }
            path.lineTo(width.toFloat(), height.toFloat())
            path.lineTo(0f, height.toFloat())
            path.close()
    
    
            // 设置path
            pathMeasure.setPath(path, false)
    
            linePaint.style = Paint.Style.FILL
            linePaint.strokeWidth = 2.dp
    
            // 裁剪表格, 只保留表格内的数据
            canvas.clipRect(0, 0, width, height)
            // 设置渐变
            linePaint.shader = LinearGradient(
                width * 1f,
                height / 2f,
                width * 1f,
                height * 1f,
                Color.RED,
                Color.YELLOW,
                Shader.TileMode.CLAMP
            )
            canvas.drawPath(path, linePaint)
            linePaint.shader = null
            path.reset()
        }
    

    绘制点:

    private fun drawPoint(canvas: Canvas) {
        paint.strokeWidth = 10.dp
    
        // 每一格的宽高
        val eachHeight = eachHeight
        val eachWidth = eachWidth
    
        originList.forEachIndexed { index, value ->
            val x = eachWidth * index + offsetX
            val y = height - eachHeight * value // 取反
    
            // 当前x在屏幕内才绘制
            if (x >= 0 && x <= width) {
                canvas.drawPoint(x, y, paint)
            }
        }
    }
    
    38AEDC0A888E6C34309C617A1603F106

    中间的点已经在最上面了,课时最左侧的点,和最上面的点,都被遮挡了

    被遮挡是因为被裁剪了,要想点不被裁剪,那么只需要将线的画布保存一下

    override fun onDraw(canvas: Canvas) {
      canvas.withSave {
          // 绘制线
          drawLine(canvas)
      }
    
    
      // 绘制点和连接线
      drawPoint(canvas)
    }
    

    最后将数据多添加一点,试试效果

    private val originList = listOf(
        70, 80, 100, 222, 60,
        70, 80, 100, 222, 60,
        777, 210, 100, 2222, 80,
        70, 880, 100, 222, 700
    )
    
    7771BEF45D002E7358D473876574FB0F

    完整代码

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

    热门文章:

  • 相关阅读:
    元胞自动机交通模型案例2
    Docker | 容器互联互通
    OceaBase 分区表创建技巧
    23 张图细讲使用 Devtron 简化 K8S 中应用开发
    Python基础入门例程15-NP15 截取用户名前10位(字符串)
    JS逆向实战14——猿人学第二题动态cookie
    [线性dp]leetcode198:打家劫舍(medium)
    LTpowerCAD II和LTpowerPlanner III
    Windows用户、组的管理
    【Qt】Qt界面美化指南:深入理解QSS样式表的应用与实践
  • 原文地址:https://blog.csdn.net/weixin_44819566/article/details/126951538