在工作之余写了个自定义View的开源项目,github代码地址,效果如上图所示。缺点是每个View只能绘制一条折线,这个功能后续待改进。
下面开始分析View是如何实现的。首先看下工程目录:
LineChartView.kt
,一个工具类ValueUtil.kt
,两个实体类AnimEntity.kt
和DataEntity.kt
,两个逻辑类AnimController.kt
和DrawController.kt
。这个六个类按照职责可以分为3个模块:
- 数据处理:
LineChartView.kt
、ValueUtil.kt
、DataEntity.kt
, - 动画处理:
AnimController.kt
、AnimEntity.kt
, - 执行绘制:
DrawController.kt
。
数据处理:
这个过程主要生成原始数据,后续的动画和绘制都是根据这些原始数据进行的,而且动画和绘制这个两个步骤不再对数据进行任何修改,所以处理数据这个过程十分重要。这些数据包括“画笔”属性、折线宽及其颜色、折线交接处“点”形状及其颜色、“点”数据列表等等,其中“点”数据列表最重要,决定了图形的最终样式。
共有6只画笔:横纵坐标画笔frameLinePaint
、文字画笔frameTextPaint
、间隔线画笔frameInternalPaint
、折线画笔linePaint
、小圆画笔fillPaint
、大圆画笔strokePaint
。
数据点的实体类DataEntity.kt
代码:
data class DataEntity(var index: Int) {
var value: Int = 0//大小
var millis: Long = 0//
var des: String = ""//
var startX: Int = 0//
var startY: Int = 0//
var stopX: Int = -1//
var stopY: Int = -1//
}
DataEntity.kt
是数据点的实体类,主要属性是value
、startX
、startY
、stopX
、stopY
这5个。
工具类ValueUtil.kt
代码:
/**
* 找出点列表最大的值,根据最大值决定纵向文案的宽度
*/
fun max(dataList: List<DataEntity>?): Int {
var maxValue = 0
if (dataList == null || dataList.isEmpty()) {
return maxValue
}
dataList
.asSequence()
.filter { it.value > maxValue }
.forEach { maxValue = it.value }
return maxValue
}
/**
* 计算出纵坐标最大值、纵坐标每段的值,两者都是VALUE_RESIDUAL(默认5)的倍数
*/
fun getRightValue(value: Int): Int {
var temp = value
while (!isRightValue(temp)) {
temp++
}
return temp
}
/**
* 是VALUE_RESIDUAL(默认5)的倍数
*/
fun isRightValue(value: Int): Boolean {
return value % VALUE_RESIDUAL == 0
}
/**
* 计算点的X坐标
*/
fun getCoordinateX(offset: Int, width: Int, index: Int, numOfPoint: Int, leftOffset: Int): Int {
val widthCorrected = width - offset
val partWidth = widthCorrected / (numOfPoint - 1)
var coordinate = offset + partWidth * index
if (coordinate < 0) {
coordinate = 0
} else if (coordinate > width) {
coordinate = width
}
if (index > 0) {
coordinate -= leftOffset//圆圈向左偏移
}
return coordinate
}
/**
* 计算点的Y坐标
*/
fun getCoordinateY(height: Int, heightOffset: Int, value: Float): Int {
val heightCorrected = height - heightOffset
var coordinate = (heightCorrected - value).toInt()
if (coordinate < 0) {
coordinate = 0
} else if (coordinate > heightCorrected) {
coordinate = heightCorrected
}
coordinate += heightOffset
return coordinate
}
这个类主要根据原始点数据计算出纵坐标的最大值、纵坐标每段的数值(纵坐标的段数默认5,根据业务需要可以更改)、每个数据点的x、y坐标,这些计算的调用发生LineChartView.kt
类的两个方法中:updateVerticalTextWidth()
和updateDrawData()
,代码如下:
private fun updateVerticalTextWidth(): Int {
if (mDatas.isEmpty()) {
return 0
}
val maxValue = max(mDatas)
val maxValueStr = maxValue.toString()
verticalEndValue = getRightValue(maxValue)
VERTICAL_PART_VALUE = getRightValue((verticalEndValue - verticalStartValue) / verticalParts)
val titleWidth = frameTextPaint.measureText(maxValueStr).toInt()
mVerticalTextWidth = padding + titleWidth + padding
return mVerticalTextWidth
}
private fun updateDrawData(width: Int, height: Int) {
if (mDatas.isEmpty() || width <= 0 || height <= 0) {
return
}
for (i in mDatas.indices) {
var dataEntity = mDatas[i]
var leftOffset = heightOffset
dataEntity.startX = getCoordinateX(mVerticalTextWidth, width, i, mDatas.size, leftOffset)
val lineHeight = height - padding - textSize - heightOffset
var value: Float = ((dataEntity.value - verticalStartValue) * lineHeight / (VERTICAL_PART_VALUE * verticalParts)).toFloat()
dataEntity.startY = getCoordinateY(height - padding - textSize, heightOffset, value)
//
var nextPos = i + 1
if (nextPos < mDatas.size) {
value = ((mDatas[nextPos].value - verticalStartValue) * lineHeight / (VERTICAL_PART_VALUE * verticalParts)).toFloat()
dataEntity.stopX = getCoordinateX(mVerticalTextWidth, width, nextPos, mDatas.size, leftOffset)
dataEntity.stopY = getCoordinateY(height - padding - textSize, heightOffset, value)
}
}
}
动画处理:
处理完原始数据,要根据这些点数据创建相应的动画,其中每个点都有一个动画,我们姑且称之为“点线动画”,每个“点线动画”包含三个属性值:alpha
、x
、y
,用实体类AnimEntity.kt
表示,代码:
data class AnimEntity(var x: Int, var y: Int) {
var alpha: Int = 0//动画执行时的alpha用来绘制圆圈的透明度
var runningAnimIndex: Int = 0//当前正在执行的动画的index
}
创建“点线动画”的代码:
val PROPERTY_X = "PROPERTY_X"
val PROPERTY_Y = "PROPERTY_Y"
val PROPERTY_ALPHA = "PROPERTY_ALPHA"
val VALUE_NONE = -1
val ALPHA_START = 0
val ALPHA_END = 255
private val ANIMATION_DURATION = 300
/**
* 包含三个子动画:alpha动画、x动画、y动画
*/
private fun createAnimator(drawData: DataEntity): ValueAnimator? {
var duration = ANIMATION_DURATION.toLong()
if (drawData.stopX <= -1) {//表示是最后一个点,那么x动画、y动画都指向自己
drawData.stopX = drawData.startX
}
if (drawData.stopY <= -1) {//表示是最后一个点,那么x动画、y动画都指向自己
drawData.stopY = drawData.startY
}
val propertyX = PropertyValuesHolder.ofInt(PROPERTY_X, drawData.startX, drawData.stopX)
val propertyY = PropertyValuesHolder.ofInt(PROPERTY_Y, drawData.startY, drawData.stopY)
val propertyAlpha = PropertyValuesHolder.ofInt(PROPERTY_ALPHA, ALPHA_START, ALPHA_END)
val animator = ValueAnimator()
animator.setValues(propertyX, propertyY, propertyAlpha)
animator.duration = duration
animator.interpolator = AccelerateDecelerateInterpolator()
animator.addUpdateListener { valueAnimator -> this@AnimController.onAnimationUpdate(valueAnimator) }
return animator
}
创建好所有的“点线动画”之后,把它们按创建顺序播放,方法是把它们放到动画列表List<Animator>中,然后使用系统API:AnimatorSet.playSequentially(List<Animator>)
和animatorSet.start()
就能播放所有的动画。
动画执行的过程中决定着View的onDraw(Canvas canvas),通过添加动画监听不断更新动画属性,View的onDraw(Canvas canvas)会根据动画属性绘制不同的画面:
private fun onAnimationUpdate(valueAnimator: ValueAnimator?) {
if (valueAnimator == null) {
return
}
val value = AnimEntity(valueAnimator.getAnimatedValue(PROPERTY_X) as Int, valueAnimator.getAnimatedValue(PROPERTY_Y) as Int)
value.alpha = valueAnimator.getAnimatedValue(PROPERTY_ALPHA) as Int
value.runningAnimIndex = getRunningAnimIndex()
mView?.get()?.onAnimationUpdated(value)//使用弱引用,否则这里可能内存泄漏
// if (value.runningAnimIndex <= 1) {
// Log.i(TAG, "value.runningAnimIndex: ${value.runningAnimIndex} value.alpha: ${value.alpha} value.x: ${value.x}")
// /*
// * MiExToast: value.runningAnimIndex: 0 value.alpha: 254 value.x: 259
// MiExToast: value.runningAnimIndex: 0 value.alpha: 255 value.x: 260
// MiExToast: value.runningAnimIndex: 0 value.alpha: 0 value.x: 260
// MiExToast: value.runningAnimIndex: 1 value.alpha: 0 value.x: 260
// MiExToast: value.runningAnimIndex: 1 value.alpha: 0 value.x: 260
// * */
// }
}
执行绘制:
绘制主要根据动画属性var animValue: AnimEntity? = null
进行的,而动画属性在动画监听器中不断更新。
绘制主要分为这几个部分:
-
drawFrameLines
:绘制x、y坐标轴, -
drawVerticalChart
:绘制垂直文案, -
drawHorizontalChart
:绘制水平文案, -
drawChart
:绘制折线和大小圆圈。
其中drawChart
:绘制折线和大小圆圈是不断变化的,稍微绕一点,其余的绘制都比较简单,因为是固定死的。
绘制大小圆圈的时候我遇到了一个坑:获取当前正在执行的动画的index,如果这个动画是临界结束的状态,这个动画的属性值会被下个即将开始的动画的属性值代替掉。x、y属性被替换没问题,因为这正是我们想要的,但是alpha被替换的话就会存在问题,当前动画临界结束的时候alpha应该是255才对,但是下一个动画的alpha的属性值是0,那么绘制当前圆圈的时候先是alpha=255绘制一次,再alpha=0绘制一次,会出现闪烁的情况,让人很难受。解决办法是我们如果判断数当前的动画是临界结束的状态,那么手动把alpha的值改为255即可。
好了,折线图这个自定义View分析完了,欢迎在github上star,有问题希望提issues或者邮件:owl@violetpersimmon.com共同学习进步。