自定义view--绘制折线图

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共同学习进步。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,029评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,395评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,570评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,535评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,650评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,850评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,006评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,747评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,207评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,536评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,683评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,342评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,964评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,772评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,004评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,401评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,566评论 2 349

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,799评论 25 707
  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 12,066评论 4 62
  • 前言 支付宝有个查看月账单的功能,最近一直在学习自定义View,于是就尝试着自己实现了一个类似的折线图。 下面是支...
    neo1949阅读 4,049评论 2 13
  • 学习能力强的人通常都不会学习得很快。因为他们知道一些关于学习的秘密。在当今的社会里,我们都在寻求快速地解决问题,轻...
    chaplinthink阅读 578评论 2 3
  • 我叫赵高明,今年23岁,目前在美国哥伦比亚读体育管理研究生。性格有些内向,会选择性地跟人交流,不过我目前正在试图成...
    赵高明阅读 112评论 0 0