图表CanvasChartView(五):扩展CanvasCha
前言
经过前几篇的努力,我们的CanvasChartView已经变得很健壮,今天我们扩展几个小功能,并且增加自定义属性,为发布CanvasChartView,做最后的收尾工作。
要添加的功能列表:
- 自定义属性,线条的颜色,粗细等等
- 增加数据点之间的连线样式为曲线
- 增加只显示x,y均为正数的情况
- 增加显示刻度值
正文
首先一张图了解一下今天的任务:
image数据点之间的连线增加曲线样式
直接贴出关键代码:
/**
* 图表的线条样式
*
* */
private var chartLineStyle: ChartLineStyle = ChartLineStyle.LINEAR
/**
* 计算数据之间的连线和数据点
* */
private fun addItemLine(canvas: Canvas, data: List<ChartBean>, path: Path, dotPath: Path) {
...
if (index == startIndex) {
path.moveTo(xPos, yPos)
} else {
// 直线
if (chartLineStyle == ChartLineStyle.LINEAR) {
path.lineTo(xPos, yPos)
}
// 曲线
else if (chartLineStyle == ChartLineStyle.CURVE) {
curveTo(index, data, startIndex, xPos, yPos, path)
}
}
index++
...
}
/**
* 平滑到下一个点
* */
private fun curveTo(index: Int, data: List<ChartBean>, startIndex: Int, xPos: Float, yPos: Float, path: Path) {
// 如果是最后一个点,不需要计算
if (index + 1 >= data.size) {
return
}
// 结束的点
val nextXPos = calculateXPosition(startIndex, index + 1)
val nextYPos = calculateYPosition(data[index + 1])
val wt = xPos + markWidth / 2
path.cubicTo(wt, yPos, wt, nextYPos, nextXPos, nextYPos)
}
/**
* 线条Style
* */
enum class ChartLineStyle(val value: String) {
/**
* 直线
* */
LINEAR("linear"),
/**
* 曲线
* */
CURVE("curve")
}
主要是Path.cubicTo方法来实现曲线,这里就不详细介绍了,大家可以去百度一下资料。
新增图表只绘制第一象限(x, y都为正数)
大部分我们需要的图表都是正数的,例如温度,收入,支出等等,所以增加只绘制第一象限的样式,还是有些必要的。直接看代码:
/**
* 是否只显示第一象限
* */
private var onlyFirstArea: Boolean = false
/**
* 画X轴
* */
private fun drawXLine(canvas: Canvas) {
val width = width.toFloat()
if (onlyFirstArea) {
// 绘制X轴
canvas.drawLine(getRealX(0f), getRealY(height - lineWidth / 2), width, getRealY(height - lineWidth / 2), paint)
} else {
// 计算y方向上的中心位置
val yCenter = (height - lineWidth) / 2
// 绘制X轴
canvas.drawLine(getRealX(0f), getRealY(yCenter), width, getRealY(yCenter), paint)
}
}
/**
* 计算每一个数据点在Y轴上的坐标
* */
private fun calculateYPosition(value: ChartBean): Float {
// 计算比例
val scale = value.number / yLineMax
// 计算y方向上的中心位置
val yCenter = if (onlyFirstArea) {
height - lineWidth
} else {
(height - lineWidth) / 2
}
// 如果小于0
return yCenter - yCenter * scale
}
主要是修改绘制X轴的位置:由原来的高度的中间位置改为最底部,其他没有变化。
getRealX和getRealY方法,是对传进来的X,Y坐标进行加工的方法,因为之后增加了刻度文字以后,整个图表的绘制位置要右移,所以提前封装好这个方法,便于之后的修改。
绘制X,Y坐标轴的刻度文字
最后绘制坐标轴的文字,也是做复杂的一步,首先我们画一张简单的逻辑图,帮助我们弄清实现的思路:
image有了这张图作为参考,我们需要准备以下工作:
- 计算Y轴文字的宽度,作为X轴偏移的距离
- 计算X轴文字的高度,作为Y轴偏移的距离
- 同理,所有绘制图表内容的计算,都必须计算XY轴偏移的距离
- 图表绘制的区域需要裁剪,否则图表滚动的时候,内容会显示在刻度文字的区域内
ok,接下来开始修改我们的代码。
首先修改BaseScroller的代码:
/**
* 绘制Y轴的偏移值,这个值用来绘制Y轴的文字
* */
protected var drawOffsetX = 0f
/**
* 绘制X轴的偏移值,这个值用来绘制X轴下面的文字
* */
protected var drawOffsetY = 0f
/**
* 把计算的X坐标加上偏移值
* */
protected fun getRealX(xPos: Float): Float {
return xPos + drawOffsetX
}
/**
* 把计算的Y坐标加上偏移值
*/
protected fun getRealY(yPos: Float): Float {
return yPos - drawOffsetY
}
首先定义变量保存偏移值的值,然后修改所有跟偏移值有关的计算,例如计算检查边界、canvas偏移的值等等,随便贴出一个例子:
/**
* 根据偏移值,计算绘制的数据的开始位置
* */
protected fun getDataStartIndex(): Int {
// 计算已经偏移了几个刻度
val index = if (dataDotGravity == DataDotGravity.CENTER) {
(offsetX - drawOffsetX - markWidth / 2) / (markWidth)
} else {
(offsetX - drawOffsetX - markWidth) / (markWidth)
}
return if (index < 0) {
0
} else {
index.toInt()
}
}
在计算开始位置的时候,要减去x方向的偏移值,才能得到正确的index。
然后修改onDraw()方法,计算drawOffsetX和drawOffsetY:
override fun onDraw(canvas: Canvas) {
......
canvas.save()
// 如果要绘制刻度值的文字,需要先计算图标的偏移距离
// y轴刻度值文字的最大宽度
if (showMarkText) {
paint.textSize = markTextSize
xMarkTextMaxWidth = getTextWidth(yLineMax.toString())
// x轴绘制的起始位置偏移值
drawOffsetX = xMarkTextMaxWidth
// y轴绘制的起始位置偏移值
val fontMetrics = paint.fontMetrics
drawOffsetY = fontMetrics.bottom - fontMetrics.top
}
// 这里要重置一下缓存,因为要开始绘制新的图标了
pathCacheManager.resetCache()
......
}
drawOffsetX等于刻度Y轴文字的宽度,drawOffsetY等于x轴文字的高度。
最后画出刻度的文字:
/**
* 绘制Y轴的文字
* */
private fun drawYMarkText(canvas: Canvas, index: Int, yLineSpace: Float) {
if (!showMarkText) {
return
}
// 设置画笔的效果
paint.color = markTextColor
paint.textSize = markTextSize
paint.pathEffect = null
paint.style = Paint.Style.FILL
val text = (yLineMax - index * yLineMax / yLineMarkCount).toString()
canvas.drawText(text,
0f, getRealY(height.toFloat()) / yLineMarkCount * index + yLineSpace,
paint)
}
首先绘制了Y轴的文字,按照最大刻度的百分比作为刻度的文字,例如现在最大是100,所以每一个刻度就是:0,20,,40,60,80,100。
X州的刻度文字跟Y轴不同,因为Y轴的文字是固定的,X轴的文字是随着滑动的距离而变化的,我们之前已经绘制了数据点的文字,所以这里直接在原有的基础上扩展:
/**
* 绘制文字
*
* */
private fun drawText(canvas: Canvas, item: ChartBean, xPos: Float, yPos: Float) {
// 绘制数据点的文字
......
// 绘制刻度值的文字
// 设置画笔的效果
if (showMarkText) {
paint.color = markTextColor
paint.textSize = markTextSize
paint.style = Paint.Style.FILL
val markTextWidth = getTextWidth(item.markText)
// 文字绘制开始的位置是基线位置,所以要把上移paint.fontMetrics.bottom距离,才能显示完整的文字
canvas.drawText(item.markText, xPos - markTextWidth / 2, height.toFloat() - paint.fontMetrics.bottom, paint)
}
}
最后千万别忘了锁住我们绘制的canvas区域,否则图表的绘制就与Y轴刻度文字重叠了:
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
......
// 绘制X轴和Y轴
drawXYLine(canvas)
// 绘制X轴的虚线
drawXDashLine(canvas)
// 从这里开始,我们要对canvas进行偏移
canvas.translate(getCanvasOffset() + lineWidth, -lineWidth)
// 裁剪要绘制的区域
// 裁剪的区域坐标记得减去偏移值,修正裁剪的位置
canvas.clipRect(getRealX(lineWidth - getCanvasOffset()), 0f, width.toFloat() - getCanvasOffset(), height.toFloat())
// 绘制每一条数据之间的间隔虚线
drawYDashLine(canvas)
// 绘制数据
drawData(canvas)
// 恢复一下canvas的状态
canvas.restore()
}
从代码上看,我们Y轴刻度文字的右侧的部分设置成需要绘制的区域,这样图表只会在这一部分进行绘制,也就解决了重叠的问题,坐标轴刻度文字到这里也完美解决。
自定义属性
自定义属性是基础中的基础,今天我们来复习一下自定义属性的用法:
首先在res/values文件夹中创建attrs.xml文件,attrs用于定义自定义属性:
image打开attrs.xml文件,写下我们需要的属性:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- BaseScrollerView的自定义属性 -->
<declare-styleable name="BaseScrollerView">
<!-- x轴显示的刻度的个数 -->
<attr name="xLineMarkCount" format="integer" />
<!-- y轴显示的刻度的个数 -->
<attr name="yLineMarkCount" format="integer" />
<!-- X轴和Y轴的宽度 -->
<attr name="lineWidth" format="dimension" />
<!-- 数据圆点的位置 -->
<attr name="dataDotGravity" format="enum" >
<enum name="line" value="0" />
<enum name="center" value="1" />
</attr>
</declare-styleable>
<!-- CanvasChartView的自定义属性 -->
<declare-styleable name="CanvasChartView">
<!-- 是否只显示第一象限 -->
<attr name="onlyFirstArea" format="boolean" />
<!-- x、y轴的颜色 -->
<attr name="lineColor" format="color" />
<!-- 图表的连线颜色 -->
<attr name="chartLineColor" format="color" />
<!-- 图表的连线宽度-->
<attr name="chartLineWidth" format="dimension" />
<!-- 图表的连线样式:直线或曲线 -->
<attr name="chartLineStyle" format="enum">
<enum name="linear" value="0" />
<enum name="curve" value="1" />
</attr>
<!-- 圆点的宽度 -->
<attr name="dotWidth" format="dimension" />
<!-- 圆点的颜色 -->
<attr name="dotColor" format="color" />
<!-- 是否显示圆点 -->
<attr name="showDataDot" format="boolean" />
<!-- 虚线的颜色 -->
<attr name="dashLineColor" format="color" />
<!-- 虚线的宽度 -->
<attr name="dashLineWidth" format="dimension" />
<!-- y轴的最大刻度 -->
<attr name="yLineMax" format="integer" />
<!-- 绘制文字的大小 -->
<attr name="textSize" format="dimension" />
<!-- 绘制文字的颜色 -->
<attr name="textColor" format="color" />
<!-- 文字和圆点之间的间距 -->
<attr name="textSpace" format="dimension" />
<!-- 是否显示刻度文字 -->
<attr name="showMarkText" format="boolean"/>
<!-- 刻度文字大小 -->
<attr name="markTextSize" format="dimension"/>
<!-- 刻度文字颜色 -->
<attr name="markTextColor" format="color"/>
</declare-styleable>
</resources>
自定属性有几个要注意的地方:
- 推荐你使用自定义属性的View的名字,和你自定义属性集合的名字相同,例如CanvasChartView属性集合只用在CanvasChartView中,如果你用在了BaseScrollerView中,会提示黄色警告。
- 属性的format类型,要注意的是enum,他的value只能是Int,如果是在获取的时候使用String类型,是不会匹配成功的。
- 如果有多个自定义集合都使用相同的属性,例如多个自定义View都有textSize,那么推荐把这个属性单独拿出来,在集合中直接使用就可以了。
然后在我们的布局xml中使用自定义属性:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">
<com.lzp.com.canvaschart.view4.CanvasChartView
android:id="@+id/canvas_chart_4"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:chartLineColor="@color/colorPrimary"
app:chartLineStyle="curve"
app:dashLineColor="@color/colorPrimaryDark"
app:dataDotGravity="center"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:lineColor="@color/colorAccent"
app:onlyFirstArea="true"
app:showDataDot="true"
app:showMarkText="true" />
</LinearLayout>
需要在使用自定义View的外层父布局(一般是根布局)中引入自定义属性
xmlns:app="http://schemas.android.com/apk/res-auto"
这个格式是固定的,app是自定义属性的命名空间,可以随便定义,一般设置为app。
这样View就可以使用自定义属性了。
最后就是得到xml中设置的自定义属性的值:
init {
val typedArray = context.obtainStyledAttributes(attributes, R.styleable.BaseScrollerView)
// 绘制X轴和Y轴的宽度
lineWidth = typedArray.getDimensionPixelSize(R.styleable.BaseScrollerView_lineWidth, 5).toFloat()
// 得到x轴的刻度数
xLineMarkCount = typedArray.getInt(R.styleable.BaseScrollerView_xLineMarkCount, 5)
// 得到y轴的刻度数
yLineMarkCount = typedArray.getInt(R.styleable.BaseScrollerView_yLineMarkCount, 5)
// 得到绘制数据点的位置
dataDotGravity = if (typedArray.getInt(R.styleable.BaseScrollerView_dataDotGravity, 0) == 0) {
DataDotGravity.LINE
} else {
DataDotGravity.CENTER
}
typedArray.recycle()
}
我使用的Kotlin,Kotlin的init方法是在构造方法中执行的,并且可以直接使用构造方法中的参数,如果是Java,可以直接在构造方法中完成。
从构造方法中的Attributes参数,取出自定义属性集合BaseScrollerView,之后根据匹配的类型一次得到值就可以了,最需要注意的是,在初始化结束后,记得调用TypedArray.recycle()方法,释放内存空间,否则会造成内存泄漏。
总结
到此为止我们的CanvasChartView功能已经非常丰富了,但是在实际开发中图表样式往往都是风格迥异,很多时候都需要自己去自定义或者根据一些相对成熟的开源库进行二次修改,所以为了摆脱产品的蹂躏,掌握绘制图表的技能真的是非常非常必要的。
最后一篇让我们一起把CanvasChartView发布到jcenter上。