自定义view--绘制折线图

自定义view--绘制折线图_第1张图片
general.gif

在工作之余写了个自定义View的开源项目,github代码地址,效果如上图所示。缺点是每个View只能绘制一条折线,这个功能后续待改进。

下面开始分析View是如何实现的。首先看下工程目录:

自定义view--绘制折线图_第2张图片
,总共6个kotlin类,其中一个自定义的View类 LineChartView.kt,一个工具类 ValueUtil.kt,两个实体类 AnimEntity.ktDataEntity.kt,两个逻辑类 AnimController.ktDrawController.kt
这个六个类按照职责可以分为3个模块:

  1. 数据处理:LineChartView.ktValueUtil.ktDataEntity.kt
  2. 动画处理:AnimController.ktAnimEntity.kt
  3. 执行绘制: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是数据点的实体类,主要属性是valuestartXstartYstopXstopY这5个。

工具类ValueUtil.kt代码:

/**
 * 找出点列表最大的值,根据最大值决定纵向文案的宽度
 */
fun max(dataList: List?): 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)
        }
    }
}

动画处理:

处理完原始数据,要根据这些点数据创建相应的动画,其中每个点都有一个动画,我们姑且称之为“点线动画”,每个“点线动画”包含三个属性值:alphaxy,用实体类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 -> [email protected](valueAnimator) }
    return animator
}

创建好所有的“点线动画”之后,把它们按创建顺序播放,方法是把它们放到动画列表List中,然后使用系统API:AnimatorSet.playSequentially(List)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进行的,而动画属性在动画监听器中不断更新。

绘制主要分为这几个部分:

  1. drawFrameLines:绘制x、y坐标轴,
  2. drawVerticalChart:绘制垂直文案,
  3. drawHorizontalChart:绘制水平文案,
  4. drawChart:绘制折线和大小圆圈。

其中drawChart:绘制折线和大小圆圈是不断变化的,稍微绕一点,其余的绘制都比较简单,因为是固定死的。

绘制大小圆圈的时候我遇到了一个坑:获取当前正在执行的动画的index,如果这个动画是临界结束的状态,这个动画的属性值会被下个即将开始的动画的属性值代替掉。x、y属性被替换没问题,因为这正是我们想要的,但是alpha被替换的话就会存在问题,当前动画临界结束的时候alpha应该是255才对,但是下一个动画的alpha的属性值是0,那么绘制当前圆圈的时候先是alpha=255绘制一次,再alpha=0绘制一次,会出现闪烁的情况,让人很难受。解决办法是我们如果判断数当前的动画是临界结束的状态,那么手动把alpha的值改为255即可。

好了,折线图这个自定义View分析完了,欢迎在github上star,有问题希望提issues或者邮件:[email protected]共同学习进步。

你可能感兴趣的:(自定义view--绘制折线图)