android自定义View: 绘制图表(一)

发布于:2022-12-12 ⋅ 阅读:(643) ⋅ 点赞:(0)

本系列自定义View全部采用kt

系统:mac

android studio: 4.1.3

kotlin version: 1.5.0

gradle: gradle-6.5-bin.zip

本篇内容: 从0到1绘制一个可控制的图表!

本篇效果:

效果1 效果2 效果3
7B75747752FF235B730EC70957982F6C 8DC6BBD616750E2520007034B8C2FB53 7AA4A7915D36ADBC46E1D46AC78356FE

绘制表格

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

完整代码

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

热门文章:

本文含有隐藏内容,请 开通VIP 后查看

网站公告

今日签到

点亮在社区的每一天
去签到