前言
愉快的五一假期说没就没,又到了上班的日子,今天不是特别忙,赶紧写点什么。
前一阵都在看源码了,看的头昏脑胀,突然想起来去年的时候,以前的同事问我对于类似天气这种app,那种温度图表是怎么做的,当时很忙,就直接让他找开源库用一下,今天就来自己写一个。
正文
说道比较火的天气app,我想到的就是墨迹天气了,先贴张图:
我们研究的主题就是这中间的这两条线,再最终的效果上,我们会慢慢向他靠近。
首先我们来简单分析一下我们需要哪些准备工作:
- 肯定要有一只画笔Paint
- 曲线的颜色和宽度
- 文字的颜色和宽度
- 每一个数据之间有虚线,虚线的颜色和宽度
- 每一个数据有圆点,圆点的颜色和半径
- 绘制了多条曲线,所以保存曲线的事List或者是Set,我觉得list这里更合适。
- 准备一个数据适配器DataAdapter, 用来处理和刷新数据
绘制主要分为两步:
- 绘制坐标轴;
- 绘制数据曲线
* 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()中的坐标,实际上是绘制文字的基线的坐标,我直接从别处截了一张图:
仔细观察上图文字区域,我们会发现文字区域中有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之间的距离。
最后就是一张效果图了:
总结
今天的内容就到此为止了,我们完成了基本的绘制功能,下一篇我们来增加手势滑动的功能。
补充
竟然忘记把源码链接发出来了,赶紧补上https://github.com/li504799868/CanvasChart/tree/7d7caeb0e565d785249aceb6a6ff94358bd12e19。
代码更新速度比内容要快,所以会有些不同,但是核心功能没变。