Android自定义控件Android开发经验谈Android开发

图表CanvasChartView(一):Canvas绘制

2018-05-02  本文已影响65人  珠穆朗玛小王子

前言

愉快的五一假期说没就没,又到了上班的日子,今天不是特别忙,赶紧写点什么。

前一阵都在看源码了,看的头昏脑胀,突然想起来去年的时候,以前的同事问我对于类似天气这种app,那种温度图表是怎么做的,当时很忙,就直接让他找开源库用一下,今天就来自己写一个。

正文

说道比较火的天气app,我想到的就是墨迹天气了,先贴张图:

image

我们研究的主题就是这中间的这两条线,再最终的效果上,我们会慢慢向他靠近。

首先我们来简单分析一下我们需要哪些准备工作:

  1. 肯定要有一只画笔Paint
  2. 曲线的颜色和宽度
  3. 文字的颜色和宽度
  4. 每一个数据之间有虚线,虚线的颜色和宽度
  5. 每一个数据有圆点,圆点的颜色和半径
  6. 绘制了多条曲线,所以保存曲线的事List或者是Set,我觉得list这里更合适。
  7. 准备一个数据适配器DataAdapter, 用来处理和刷新数据

绘制主要分为两步:

  1. 绘制坐标轴;
  2. 绘制数据曲线
 * Created by li.zhipeng on 2018/5/2.
 */
class CanvasChartView(context: Context, attributes: AttributeSet?, defStyleAttr: Int)
    : View(context, attributes, defStyleAttr) {

    constructor(context: Context, attributes: AttributeSet?) : this(context, attributes, 0)

    constructor(context: Context) : this(context, null)

    /**
     * 画笔
     *
     * 设置抗锯齿和防抖动
     * */
    private val paint: Paint by lazy {
        val field = Paint()
        field.isAntiAlias = true
        field.isDither = true
        field
    }

    /**
     * 绘制X轴和Y轴的颜色
     *
     *  默认是系统自带的蓝色
     * */
    var lineColor: Int = Color.BLUE

    /**
     * 绘制X轴和Y轴的宽度
     * */
    var lineWidth = 5f

    /**
     * 图表的颜色
     * */
    var chartLineColor: Int = Color.RED

    /**
     * 图表的宽度
     * */
    var chartLineWidth: Float = 3f

    /**
     * 圆点的宽度
     * */
    var dotWidth = 15f

    /**
     * 圆点的颜色
     * */
    var dotColor: Int = Color.BLACK

    /**
     * 虚线的颜色
     * */
    var dashLineColor: Int = Color.GRAY

    /**
     * 虚线的颜色
     * */
    var dashLineWidth: Float = 2f

    /**
     * x轴的刻度间隔
     *
     * 因为x周是可以滑动的,所以只有刻度的数量这一个属性
     * */
    var xLineMarkCount: Int = 5

    /**
     * y轴的最大刻度
     * */
    var yLineMax: Int = 100

    /**
     * 绘制文字的大小
     * */
    var textSize: Float = 40f

    /**
     * 绘制文字的颜色
     * */
    var textColor: Int = Color.BLACK

    /**
     * 文字和圆点之间的间距
     * */
    var textSpace: Int = 0

    /**
     * 数据适配器
     * */
    var adapter: BaseDataAdapter? = null
        set(value) {
            field = value
            invalidate()
            value?.addObserver { _, _ ->
                // 当数据发生改变的时候,立刻重绘
                invalidate()
            }
        }
 override fun onDraw(canvas: Canvas) {super.onDraw(canvas)
      // 绘制X轴和Y轴
      drawXYLine(canvas)
      // 绘制数据
      drawData(canvas)</pre>
 }

基本的变量都已经准备完毕了, 并且在onDraw方法里预先创建了绘制坐标轴和数据曲线的方法,我们先从简单画起,例如先画坐标轴:

/**
     * 绘制X轴和Y轴
     *
     * x轴位于中心位置,值为0
     * y轴位于最最左边,与x轴交叉,交叉点为0
     * */
    private fun drawXYLine(canvas: Canvas) {
        // 设置颜色和宽度
        paint.color = lineColor
        paint.strokeWidth = lineWidth
        paint.style = Paint.Style.STROKE
        drawXLine(canvas)
        drawYLine(canvas)
    }

    /**
     * 画X轴
     * */
    private fun drawXLine(canvas: Canvas) {
        val width = width.toFloat()
        // 计算y方向上的中心位置
        val yCenter = (height - lineWidth) / 2
        // 绘制X轴
        canvas.drawLine(0f, yCenter, width, yCenter, paint)
    }

    /**
     * 画Y轴
     * */
    private fun drawYLine(canvas: Canvas) {
        // 计算一下Y轴的偏移值
        val offsetY = lineWidth / 2
        // 绘制Y轴
        canvas.drawLine(offsetY, 0f, offsetY, height.toFloat(), paint)
        // 绘制每一条数据之间的间隔虚线
        drawDashLine(canvas)
    }

    /**
     * 绘制数据之间
     * */
    private fun drawDashLine(canvas: Canvas) {
        // 画条目之间的间隔虚线
        var index = 1
        // 通过x轴的刻度数量,计算x轴坐标
        val xItemSpace = width / xLineMarkCount.toFloat()
        paint.color = dashLineColor
        paint.strokeWidth = dashLineWidth
        paint.pathEffect = DashPathEffect(floatArrayOf(10f, 10f), 1f)
        while (index < xLineMarkCount) {
            val startY = xItemSpace * index
            val path = Path()
            path.moveTo(startY, 0f)
            path.lineTo(startY, height.toFloat())
            canvas.drawPath(path, paint)
            index++
        }
    }

绘制坐标轴算是最简单的事情了,但是仍然有几点需要注意:

虚线是数据之间的间隔,所以最后一条不需要画。

绘制完虚线,就可以直接绘制数据曲线了,之前我们创建了DataAdapter,在里面设置和保存数据,看一下代码:

/**
 * Created by li.zhipeng on 2018/5/2.
 *
 *      图标的数据适配器
 */
class BaseDataAdapter : Observable() {

    /**
     * 保存数据
     * */
    private val dataList: ArrayList<List<Int>> = ArrayList()

    /**
     * 添加数据
     * */
    fun addData(data: List<Int>) {
        dataList.add(data)
        notifyDataSetChanged()
    }

    fun removeAt(index: Int) {
        dataList.removeAt(index)
        notifyDataSetChanged()
    }

    fun remove(data: List<Int>) {
        dataList.remove(data)
        notifyDataSetChanged()
    }

    fun getData(): ArrayList<List<Int>> = dataList

    fun notifyDataSetChanged() {
        setChanged()
        notifyObservers()
    }
}

非常的简单,因为要绘制多条曲线,所以是addData,还有删除remove方法,notifyDataSetChanged()当数据发生改变的时候,通知View刷新,回顾一下View的代码:

/**
     * 数据适配器
     * */
    var adapter: BaseDataAdapter? = null
        set(value) {
            field = value
            invalidate()
            value?.addObserver { _, _ ->
                // 当数据发生改变的时候,立刻重绘
                invalidate()
            }
        }

这里使用了一个系统自带的观察者模式,当adapter中的数据发生改变了,View进行重绘。

最后是接下来就是最重要的绘制数据曲线了,现在我们要去完善之前定义好的drawData方法:

/**
     * 绘制数据曲线
     * */
    private fun drawData(canvas: Canvas) {
        // 设置画笔样式
        paint.pathEffect = null
        // 得到数据列表, 如果是null,取消绘制
        val dataList = adapter?.getData() ?: return
        // 绘制每一条数据列表
        for (item in dataList) {
            drawItemData(canvas, item)
        }
    }

    /**
     * 绘制一条数据曲线
     * */
    private fun drawItemData(canvas: Canvas, data: List<ChartBean>) {
        // 通过x轴的刻度间隔,计算x轴坐标
        val xItemSpace = width / xLineMarkCount
        val path = Path()
        val dotPath = Path()
        for ((index, item) in data.withIndex()) {
            // 计算每一个点的位置
            val xPos = (xItemSpace / 2 + index * xItemSpace).toFloat()
            val yPos = calculateYPosition(item)
            if (index == 0) {
                path.moveTo(xPos, yPos)
            } else {
                path.lineTo(xPos, yPos)
            }
            dotPath.addCircle(xPos, yPos, dotWidth, Path.Direction.CW) // 保存圆点的坐标信息
            // 绘制文字
            drawText(canvas, item, xPos, yPos)
        }
        // 绘制曲线
        paint.style = Paint.Style.STROKE
        paint.color = chartLineColor
        paint.strokeWidth = chartLineWidth
        canvas.drawPath(path, paint)
        // 绘制圆点
        paint.color = dotColor
        paint.style = Paint.Style.FILL
        canvas.drawPath(dotPath, paint)
    }

    /**
     * 计算每一个数据点在Y轴上的坐标
     * */
    private fun calculateYPosition(value: ChartBean): Float {
        // 计算比例
        val scale = value.number / yLineMax
        // 计算y方向上的中心位置
        val yCenter = (height - lineWidth) / 2
        // 如果小于0
        return yCenter - yCenter * scale
    }

    /**
     * 绘制文字
     * */
    private fun drawText(canvas: Canvas, item: ChartBean, xPos: Float, yPos: Float) {
        val text = item.text
        paint.textSize = textSize
        paint.color = textColor
        paint.style = Paint.Style.FILL
        val textWidth = paint.measureText(text)
        val fontMetrics = paint.fontMetrics
   // 文字自带的间距,不理解的可以查一下:如何绘制文字居中
      val offset = fontMetrics.ascent + (fontMetrics.ascent - fontMetrics.top)
      if (item.number > 0) {
           // 要把文字自带的间距减去,统一和圆点之间的间距
          canvas.drawText(text, xPos - textWidth / 2, yPos - dotWidth - fontMetrics.descent - textSpace, paint)
      } else {
          // 要把文字自带的间距减去,统一和圆点之间的间距
          canvas.drawText(text, xPos - textWidth / 2, yPos + dotWidth  - offset + textSpace, paint)
      }
}

绘制曲线有几点注意的地方:

重点说明一下文字绘制的部分:

文字的绘制一直是比较蛋疼的问题,网上相关的资料也有很多,我在这里简单的做一个总结,我们设置的canvas.drawText()中的坐标,实际上是绘制文字的基线的坐标,我直接从别处截了一张图:

image

仔细观察上图文字区域,我们会发现文字区域中有5条颜色不同的线。按着从上到下的顺序,他们的名字分别是:

top:浅灰色

ascent:黄色

baseline:红色

descent:蓝色

bottom:绿色

这5条线的值是以baseline为基准的,baseline等于0, baseline上面的线是负数,baseline下面的线是正数。

当数据在标准线(x轴y轴交叉点)以上时:

yPos - dotWidth // 得到的是绘制文字的基线

基线就是红线,所以我们的文字,例如“g”这个字母的小尾巴正好被挡住了,所以我们要把文字向上偏移descent的距离。textSpace是我们自定义的间距,这里就直接忽略了

// 要把文字自带的间距减去,统一和圆点之间的间距
canvas.drawText(text, xPos - textWidth / 2, yPos - dotWidth - fontMetrics.descent - textSpace, paint)

当数据在标准线(x轴y轴交叉点)以下时:

// 文字自带的间距,不理解的可以查一下:如何绘制文字居中
val offset = fontMetrics.ascent + (fontMetrics.ascent - fontMetrics.top)
canvas.drawText(text, xPos - textWidth / 2, yPos + dotWidth  - offset + textSpace, paint)

我们设置的基线正好是圆点的底部,所以我们要偏移accent的距离,再加上accent和top之间的距离。

最后就是一张效果图了:

image

总结

今天的内容就到此为止了,我们完成了基本的绘制功能,下一篇我们来增加手势滑动的功能。

补充

竟然忘记把源码链接发出来了,赶紧补上https://github.com/li504799868/CanvasChart/tree/7d7caeb0e565d785249aceb6a6ff94358bd12e19

代码更新速度比内容要快,所以会有些不同,但是核心功能没变。

上一篇 下一篇

猜你喜欢

热点阅读