图表CanvasChartView(一):Canvas绘制

前言

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

前一阵都在看源码了,看的头昏脑胀,突然想起来去年的时候,以前的同事问我对于类似天气这种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++
        }
    }

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

  • X轴在高度的最中间,Paint在绘制边框Border的时候,会以坐标为准,左右同时变粗,所以要减去Border的宽度的一半;
  • Y轴同理,在x方向上偏移Border的宽度的一半,否则你会发现线会比设置的要细。
  • 绘制Path的时候注意:设置Paint.style = Paint.Style.STROKE,否则绘制的线条有问题

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

绘制完虚线,就可以直接绘制数据曲线了,之前我们创建了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)
      }
}

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

  • 因为之前画的是虚线,所以我们先把虚线的效果去掉,Paint.pathEffect = null;
  • 因为圆点是在曲线的上面,所以创建了两个Path,分别保存Path路径,并且先绘制了曲线后绘制圆点;

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

文字的绘制一直是比较蛋疼的问题,网上相关的资料也有很多,我在这里简单的做一个总结,我们设置的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

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,482评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,377评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,762评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,273评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,289评论 5 373
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,046评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,351评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,988评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,476评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,948评论 2 324
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,064评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,712评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,261评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,264评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,486评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,511评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,802评论 2 345

推荐阅读更多精彩内容