前言
愉快的五一假期說沒就沒届谈,又到了上班的日子徙融,今天不是特別忙,趕緊寫點什么涡戳。
前一陣都在看源碼了浪规,看的頭昏腦脹或听,突然想起來去年的時候,以前的同事問我對于類似天氣這種app笋婿,那種溫度圖表是怎么做的誉裆,當(dāng)時很忙,就直接讓他找開源庫用一下缸濒,今天就來自己寫一個足丢。
正文
說道比較火的天氣app粱腻,我想到的就是墨跡天氣了,先貼張圖:
我們研究的主題就是這中間的這兩條線斩跌,再最終的效果上绍些,我們會慢慢向他靠近。
首先我們來簡單分析一下我們需要哪些準(zhǔn)備工作:
- 肯定要有一只畫筆Paint
- 曲線的顏色和寬度
- 文字的顏色和寬度
- 每一個數(shù)據(jù)之間有虛線耀鸦,虛線的顏色和寬度
- 每一個數(shù)據(jù)有圓點柬批,圓點的顏色和半徑
- 繪制了多條曲線,所以保存曲線的事List或者是Set袖订,我覺得list這里更合適氮帐。
- 準(zhǔn)備一個數(shù)據(jù)適配器DataAdapter, 用來處理和刷新數(shù)據(jù)
繪制主要分為兩步:
- 繪制坐標(biāo)軸;
- 繪制數(shù)據(jù)曲線
* 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)
/**
* 畫筆
*
* 設(shè)置抗鋸齒和防抖動
* */
private val paint: Paint by lazy {
val field = Paint()
field.isAntiAlias = true
field.isDither = true
field
}
/**
* 繪制X軸和Y軸的顏色
*
* 默認(rèn)是系統(tǒng)自帶的藍(lán)色
* */
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周是可以滑動的洛姑,所以只有刻度的數(shù)量這一個屬性
* */
var xLineMarkCount: Int = 5
/**
* y軸的最大刻度
* */
var yLineMax: Int = 100
/**
* 繪制文字的大小
* */
var textSize: Float = 40f
/**
* 繪制文字的顏色
* */
var textColor: Int = Color.BLACK
/**
* 文字和圓點之間的間距
* */
var textSpace: Int = 0
/**
* 數(shù)據(jù)適配器
* */
var adapter: BaseDataAdapter? = null
set(value) {
field = value
invalidate()
value?.addObserver { _, _ ->
// 當(dāng)數(shù)據(jù)發(fā)生改變的時候上沐,立刻重繪
invalidate()
}
}
override fun onDraw(canvas: Canvas) {super.onDraw(canvas)
// 繪制X軸和Y軸
drawXYLine(canvas)
// 繪制數(shù)據(jù)
drawData(canvas)</pre>
}
基本的變量都已經(jīng)準(zhǔn)備完畢了, 并且在onDraw方法里預(yù)先創(chuàng)建了繪制坐標(biāo)軸和數(shù)據(jù)曲線的方法楞艾,我們先從簡單畫起参咙,例如先畫坐標(biāo)軸:
/**
* 繪制X軸和Y軸
*
* x軸位于中心位置,值為0
* y軸位于最最左邊硫眯,與x軸交叉蕴侧,交叉點為0
* */
private fun drawXYLine(canvas: Canvas) {
// 設(shè)置顏色和寬度
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)
// 繪制每一條數(shù)據(jù)之間的間隔虛線
drawDashLine(canvas)
}
/**
* 繪制數(shù)據(jù)之間
* */
private fun drawDashLine(canvas: Canvas) {
// 畫條目之間的間隔虛線
var index = 1
// 通過x軸的刻度數(shù)量,計算x軸坐標(biāo)
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++
}
}
繪制坐標(biāo)軸算是最簡單的事情了舟铜,但是仍然有幾點需要注意:
- X軸在高度的最中間戈盈,Paint在繪制邊框Border的時候,會以坐標(biāo)為準(zhǔn)谆刨,左右同時變粗塘娶,所以要減去Border的寬度的一半;
- Y軸同理痊夭,在x方向上偏移Border的寬度的一半刁岸,否則你會發(fā)現(xiàn)線會比設(shè)置的要細(xì)。
- 繪制Path的時候注意:設(shè)置Paint.style = Paint.Style.STROKE她我,否則繪制的線條有問題
虛線是數(shù)據(jù)之間的間隔虹曙,所以最后一條不需要畫。
繪制完虛線番舆,就可以直接繪制數(shù)據(jù)曲線了酝碳,之前我們創(chuàng)建了DataAdapter,在里面設(shè)置和保存數(shù)據(jù)恨狈,看一下代碼:
/**
* Created by li.zhipeng on 2018/5/2.
*
* 圖標(biāo)的數(shù)據(jù)適配器
*/
class BaseDataAdapter : Observable() {
/**
* 保存數(shù)據(jù)
* */
private val dataList: ArrayList<List<Int>> = ArrayList()
/**
* 添加數(shù)據(jù)
* */
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()當(dāng)數(shù)據(jù)發(fā)生改變的時候贝搁,通知View刷新,回顧一下View的代碼:
/**
* 數(shù)據(jù)適配器
* */
var adapter: BaseDataAdapter? = null
set(value) {
field = value
invalidate()
value?.addObserver { _, _ ->
// 當(dāng)數(shù)據(jù)發(fā)生改變的時候芽偏,立刻重繪
invalidate()
}
}
這里使用了一個系統(tǒng)自帶的觀察者模式雷逆,當(dāng)adapter中的數(shù)據(jù)發(fā)生改變了,View進(jìn)行重繪污尉。
最后是接下來就是最重要的繪制數(shù)據(jù)曲線了膀哲,現(xiàn)在我們要去完善之前定義好的drawData方法:
/**
* 繪制數(shù)據(jù)曲線
* */
private fun drawData(canvas: Canvas) {
// 設(shè)置畫筆樣式
paint.pathEffect = null
// 得到數(shù)據(jù)列表, 如果是null,取消繪制
val dataList = adapter?.getData() ?: return
// 繪制每一條數(shù)據(jù)列表
for (item in dataList) {
drawItemData(canvas, item)
}
}
/**
* 繪制一條數(shù)據(jù)曲線
* */
private fun drawItemData(canvas: Canvas, data: List<ChartBean>) {
// 通過x軸的刻度間隔十厢,計算x軸坐標(biāo)
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) // 保存圓點的坐標(biāo)信息
// 繪制文字
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)
}
/**
* 計算每一個數(shù)據(jù)點在Y軸上的坐標(biāo)
* */
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) {
// 要把文字自帶的間距減去,統(tǒng)一和圓點之間的間距
canvas.drawText(text, xPos - textWidth / 2, yPos - dotWidth - fontMetrics.descent - textSpace, paint)
} else {
// 要把文字自帶的間距減去蛮放,統(tǒng)一和圓點之間的間距
canvas.drawText(text, xPos - textWidth / 2, yPos + dotWidth - offset + textSpace, paint)
}
}
繪制曲線有幾點注意的地方:
- 因為之前畫的是虛線,所以我們先把虛線的效果去掉奠宜,Paint.pathEffect = null;
- 因為圓點是在曲線的上面包颁,所以創(chuàng)建了兩個Path,分別保存Path路徑压真,并且先繪制了曲線后繪制圓點娩嚼;
重點說明一下文字繪制的部分:
文字的繪制一直是比較蛋疼的問題,網(wǎng)上相關(guān)的資料也有很多滴肿,我在這里簡單的做一個總結(jié)岳悟,我們設(shè)置的canvas.drawText()中的坐標(biāo),實際上是繪制文字的基線的坐標(biāo)泼差,我直接從別處截了一張圖:
仔細(xì)觀察上圖文字區(qū)域贵少,我們會發(fā)現(xiàn)文字區(qū)域中有5條顏色不同的線。按著從上到下的順序堆缘,他們的名字分別是:
top:淺灰色
ascent:黃色
baseline:紅色
descent:藍(lán)色
bottom:綠色
這5條線的值是以baseline為基準(zhǔn)的滔灶,baseline等于0, baseline上面的線是負(fù)數(shù)吼肥,baseline下面的線是正數(shù)录平。
當(dāng)數(shù)據(jù)在標(biāo)準(zhǔn)線(x軸y軸交叉點)以上時:
yPos - dotWidth // 得到的是繪制文字的基線
基線就是紅線,所以我們的文字缀皱,例如“g”這個字母的小尾巴正好被擋住了斗这,所以我們要把文字向上偏移descent的距離。textSpace是我們自定義的間距啤斗,這里就直接忽略了
// 要把文字自帶的間距減去表箭,統(tǒng)一和圓點之間的間距
canvas.drawText(text, xPos - textWidth / 2, yPos - dotWidth - fontMetrics.descent - textSpace, paint)
當(dāng)數(shù)據(jù)在標(biāo)準(zhǔn)線(x軸y軸交叉點)以下時:
// 文字自帶的間距,不理解的可以查一下:如何繪制文字居中
val offset = fontMetrics.ascent + (fontMetrics.ascent - fontMetrics.top)
canvas.drawText(text, xPos - textWidth / 2, yPos + dotWidth - offset + textSpace, paint)
我們設(shè)置的基線正好是圓點的底部争占,所以我們要偏移accent的距離燃逻,再加上accent和top之間的距離序目。
最后就是一張效果圖了:
總結(jié)
今天的內(nèi)容就到此為止了,我們完成了基本的繪制功能伯襟,下一篇我們來增加手勢滑動的功能猿涨。
補(bǔ)充
竟然忘記把源碼鏈接發(fā)出來了,趕緊補(bǔ)上https://github.com/li504799868/CanvasChart/tree/7d7caeb0e565d785249aceb6a6ff94358bd12e19姆怪。
代碼更新速度比內(nèi)容要快叛赚,所以會有些不同,但是核心功能沒變稽揭。