自定义view--绘制折线图

作者: 宛丘之上兮 | 来源:发表于2017-12-29 20:16 被阅读47次
    general.gif

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

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

    ,总共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<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)
            }
        }
    }
    

    动画处理:

    处理完原始数据,要根据这些点数据创建相应的动画,其中每个点都有一个动画,我们姑且称之为“点线动画”,每个“点线动画”包含三个属性值: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 -> 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进行的,而动画属性在动画监听器中不断更新。

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

    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或者邮件:owl@violetpersimmon.com共同学习进步。

    相关文章

      网友评论

        本文标题:自定义view--绘制折线图

        本文链接:https://www.haomeiwen.com/subject/byqwgxtx.html