Echars-折线图系列

我们知道,大数据时代,折线图是展示数据最重要的工具之一,不管在移动端还是PC端都有着极其繁多的折线图统计产品,比如:

  • 掘金个人中心的基础折线图
  • 招商银行月度明细的弧度折线图
  • 支付宝年收入的基础折线图
  • 除了上面各类移动端折线图,PC端还有各类常用的折线图,如下图,这里我就不一一列举了。

通过前面章节的学习,大家应该对绘制和手势以及动画有了一定基础的认识。当然大家也在前面章节学习了特别炫酷和具有难度的自定义内容,但是Echars作为前后端开发中最常用的统计图,我们何不来尝试挑战一波,本篇将带领大家实现Echars 里具有代表性的部分折线图,包括:

  • 基础折线图;
  • 基础折线面积图;
  • 折线堆叠图。

这些折线图是很多网站和移动端最常用的折线图,掌握了它们,基本就可以满足大家日常开发使用了。在工作中,你可以举一反三,这样在面对产品时,也可以很自信地说,"只有你们想不到,没有我办不到!"

接下来我们就逐一来实现这三种折线图。

绘制基础折线图

首先要说明一下,绘制过程中为了更好的让大家理解原点位置、绘制区域等,都会开始采用一些背景颜色或者绘制几何区域来帮助大家理解。

1. 创建绘制区域

可能很多同学会问,为什么老要创建绘制区域?作为初学者,对于二维坐标系的熟练度和变换认识可能稍加缺乏。为了让这部分伙伴在学习过程中能更好理解小册,所以咋们最好是创建绘制区域,标记不同的背景颜色。

图片如下,绿色部分是屏幕背景,黄色部分是自定义View绘制区域,原点在左上角:

kotlin 复制代码
/*com/example/draw_android/section16_line_chart/a_basic_line_chart/BasicLineChartView.kt*/
package com.example.draw_android.section16_line_chart.a_basic_line_chart

import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.util.AttributeSet
import android.view.View

/**
 * Created by wangfei44 on 2022/1/29.
 */
class BasicLineChartView constructor(context: Context, attributeSet: AttributeSet) :
    View(context, attributeSet) {
    init {

    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        canvas.drawCircle(
            0f, 0f, 60f,
            Paint().apply {
                color = Color.BLACK
                style = Paint.Style.FILL
            })
    }
}
2. 创建坐标系和刻度

绘制最重要的一个步骤是什么呢?当然是创建坐标系。在前文我们就说过,因为所有的绘制都是依赖于坐标系作为参照来进行绘制,如果坐标系不符合当前绘制的内容,每一个点的绘制将会带来大量的计算,每一次不同位置的绘制都会带来很多次坐标的变换,加入手势之后,手势坐标系和绘制坐标系之间的映射将是极大的挑战,可见创建坐标系有多重要。

此案例,很明显,将圆点放置在左下角,向上向右作为正方向是我们最明智的创建选择。坐标系的创建过程也就是我们画布变换过程。

画布坐标系变换代码如下:

kotlin 复制代码
override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
    //画布整体向下平移
    canvas.translate(0f,height.toFloat())
    //画布沿X轴纵向翻转
    canvas.scale(1f,-1f)
    //原点位置画圆圈做为参照物
    canvas.drawCircle(
        0f, 0f, 60f,
        Paint().apply {
            color = Color.BLACK
            style = Paint.Style.FILL
        })
}

绘制X轴刻度时,根据数据最大值和宽度以及刻度个数进行分段。通过循环变换画布绘制刻度线。

scss 复制代码
private val marginWidth = 60f
private fun drawBottomScaleLine(canvas: Canvas) {
    //绘制最底部一条横线
    canvas.drawLine(marginWidth, marginWidth, width - marginWidth, marginWidth,
        Paint().apply {
            color = Color.BLACK
            style = Paint.Style.STROKE
            strokeWidth = 2f
        })

    //绘制水平方向刻度线
    //总的宽度= 总的画布宽度-两边的边距
    val widthScale = width - 2 * marginWidth
    //水平方向总共7个天数,8个刻度短线,每一份刻度之间间距 = widthScale/7
    val eachScale = widthScale / 7f
    //保存当前画布矩阵到堆栈
    canvas.save()
    for (index in 0 until 8) {
        canvas.drawLine(marginWidth, marginWidth, marginWidth, marginWidth - 10,
            Paint().apply {
                color = Color.BLACK
                style = Paint.Style.STROKE
                strokeWidth = 2f
            })
        canvas.translate(eachScale, 0f)
    }
    canvas.restore()
}

private fun drawOtherScaleLine(canvas: Canvas) {
    //Y轴方向总共6条线进行逐步绘制
    val eachYScale = (height - 2*marginWidth)/6f
    canvas.save()
    for (index in 0 until 6){
        canvas.translate(0f,eachYScale)
        canvas.drawLine(marginWidth, marginWidth, width - marginWidth, marginWidth,
            Paint().apply {
                color = Color.argb(255,215,215,215)
                style = Paint.Style.STROKE
                strokeWidth = 2f
            })
    }
    canvas.restore()

}
2. 绘制刻度文字

必须清楚认识到,绘制文字的难点就是画布状态以及画布坐标系变换的使用。学好这一点,任其复杂多变,千姿百态,你都能够绘制出它所在的位置和样式。

接下来通过图文说明进行绘制:

X轴最下边的文字和Y轴左边文字的绘制,首先咋们分析一下文字所在位置,文字都显示在刻度中间,也就是我们文字绘制的位置可以是刻度中间的位置-位子长度的一般。如下图 start = a-textWidth/2

scss 复制代码
private fun drawXScaleText(canvas: Canvas) {
    val xTextPaint = Paint().apply {
        color = Color.BLACK
        style = Paint.Style.STROKE
        strokeWidth = 2f
        textSize = 18f
    }
    //总的宽度= 总的画布宽度-两边的边距
    val widthScale = width - 2 * marginWidth
    val eachScale = widthScale / 7f
    //保存当前画布矩阵到堆栈
    canvas.save()
    canvas.translate(marginWidth, 20f)
    for (index in 0 until 7) {
        //画布从左往右逐步平移每个刻度,方便绘制。
        canvas.save()
        //字体是上下颠倒,这里可以通过scale进行反转
        canvas.scale(1f, -1f)
        val rect = Rect()
        xTextPaint.getTextBounds(xScaleText[index], 0, xScaleText[index].length, rect)
        canvas.drawText(
            xScaleText[index], eachScale / 2f - rect.width() / 2f,
            0f,
            xTextPaint
        )
        canvas.restore()
        canvas.translate(eachScale, 0f)
    }
    //将上次保存的画布矩阵推出堆栈,画布坐标回到左下角
    canvas.restore()
}

接下来是Y轴方向的文字绘制,你也可以自己找个草纸操作下。

同样可以分析,文字开始绘制的位置,距离Y轴左边一个文字宽度稍宽点,而竖直方向距离刻度线文字高度的一半。

scss 复制代码
private fun drawYScaleText(canvas: Canvas) {
    val xTextPaint = Paint().apply {
        color = Color.BLACK
        style = Paint.Style.STROKE
        strokeWidth = 2f
        textSize = 18f
    }
    //Y轴方向总共6条线进行逐步绘制
    val eachYScale = (height - 2 * marginWidth) / 6f
    canvas.save()
    canvas.translate(marginWidth, marginWidth)
    for (index in 0 until 7) {
        //画布从左往右逐步平移每个刻度,方便绘制。
        canvas.save()
        //字体是上下颠倒,这里可以通过scale进行反转
        canvas.scale(1f, -1f)
        val rect = Rect()
        val yScaleText = (50*index).toString()
        xTextPaint.getTextBounds(
            yScaleText,
            0,
            yScaleText.length,
            rect
        )
        canvas.drawText(
            yScaleText, -(rect.width()+20f),
            rect.height()/2f,
            xTextPaint
        )
        canvas.restore()
        canvas.translate(0f, eachYScale)
    }
    canvas.restore()

}
3.绘制数据

坐标系创建和刻度绘制之后,就该进行绘制数据了,这是这是最重要的一步了。

绘制数据过程中最重要的点是什么呢?自定义过的开发者应该都明白,将数据映射到屏幕画布坐标系内才是重点,那我们应该如何做好映射关系呢?接下来咋们一步步进行分析。

我们可以看到最大刻度scaleMax=300,而画布高度mHeight=height-marginWidth*2,那么每一单位刻度的实际像素高度 scale = mHeight / scaleMax,那每个数据的实际像素高度我们也可以计算 = data * scale

ini 复制代码
private fun drawData(canvas: Canvas) {
    val widthScale = width - 2 * marginWidth
    //水平方向总共7个天数,8个刻度短线,每一份刻度之间间距 = widthScale/7
    val eachScale = widthScale / 7f
    //每一刻度所占的实际像素
    val scale = (height - marginWidth * 2) / 300f
    canvas.save()
    canvas.translate(marginWidth, marginWidth)
    val path = Path()
    //绘制起点开始
    path.moveTo(eachScale / 2, dataArray[0] * scale)
    for (index in 1 until dataArray.size) {
        path.lineTo(eachScale / 2 + eachScale * index, dataArray[index] * scale)
    }
    canvas.drawPath(path, Paint().apply {
        color = Color.argb(255, 103, 123, 211)
        style = Paint.Style.STROKE
        strokeWidth = 6f
    })
    for (index in 0 until dataArray.size){
        //绘制圆圈
        canvas.drawCircle(
            eachScale / 2 + eachScale * index,
            dataArray[index] * scale,
            6f,
            Paint().apply {
                color = Color.WHITE
                style = Paint.Style.FILL
                strokeWidth = 6f
            })
         //绘制圆的边   
        canvas.drawCircle(
            eachScale / 2 + eachScale * index,
            dataArray[index] * scale,
            8f,
            Paint().apply {
                color = Color.argb(255, 103, 123, 211)
                style = Paint.Style.STROKE
                strokeWidth = 6f
            })
    }
    canvas.restore()
}
4.增加动画

动画是UI中引起人们注意力,也是产品美感设计中极其重要的元素。一个产品中的动画,可能更让其更受欢迎,更具有活力和魅力。

大家平时是否注意过,自定义中的动画一般都是出现在哪里呢?据我观察和开发经验来说,大多都是添加到其路径或者绘制区域,绘制路径的动画当然离开不了路径的测量了。接下来咋们会继续认识和巩固之前学过的内容。

动画效果我们可以看到路径从左往右逐渐出现。路径的测量PathMeasure以及getSegment获取任意两位置间的路径,附加一个动画就可以搞定,详细API介绍可以看前面章节。

kotlin 复制代码
val mPathMeasure=PathMeasure(path, false)
val length:Float= mPathMeasure.length
val stop: Float = length
val start = 0f
//用于截取整个path中某个片段,通过参数startD和stopD来控制截取的长度,
// 并将截取后的path保存到参数dst中,最后一个参数表示起始点是否使用moveTo将路径的新起始点移到结果path的起始点中,
// 通常设置为true
val resultGreenPath=Path()
mPathMeasure.getSegment(start, stop*(mCurAnimValue), resultGreenPath, true)
5.布局自适应

判断一个产品的兼容性,我想都离不开布局的适配了。当你自定义一个精美的View,是否可以成功适配不同的手机分辨率?不同的平板分辨率?不同的小窗模式?不同的浮窗模式?等。那屏幕适配需要注意哪些环节呢?

我的回答是:

  1. 处理好数据和屏幕坐标系之间的映射。
  1. 对于画布变换过程中绘制考虑的细节。例如,边距的考虑,画布变换时候的对于整体坐标的影响,以及画布状态的保存和释放。

我们通过平板电脑模式,来看看不同尺寸是否适配,好的自定义一定具有很好的屏幕适配能力。由于上面咋们都是通过数据和画布的宽高进行计算,所以猜想适配应该还可以的。下面通过录制GIF看看适配能力。

绘制基础折线面积图

二、绘制基础折线面积图

接下来咋们来绘制Echars案例里面的面积图。

1.绘制坐标系和刻度

通过前面的章节我们已经熟练掌握了画布的变换,所以坐标系和刻度的绘制有很多方式可以实现。不能以我的案例代码为唯一写法。

可以看到此案例和案例一的坐标系以及刻度线几乎一致,竖直方向有6个刻度,水平方向有7个刻度。不同在于x轴刻度文字在每个刻度下方,并不在刻度中间,且坐标位置和画布区域有margin大小的空白区域来绘制文字刻度。

css 复制代码
通过上图分析咋们最好是将坐标系变换到左下角才会更好的进行绘制。从左上角变换到左下角。
canvas.translate(0f, height.toFloat())
canvas.scale(1f, -1f)
canvas.translate(margin, margin)
如下图是画布变换过程1-2-3-4完成。

我们计算刻度间隔,然后通过canvas.translate进行平移画布通过canvas.drawLine绘制刻度线。

kotlin 复制代码
package com.example.draw_android.section16_line_chart.b_basic_area_chart
/*app/src/main/java/com/example/draw_android/section16_line_chart/b_basic_area_chart/BasicAreaChartView.kt*/
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.util.AttributeSet
import android.view.View

/**
 * Created by wangfei44 on 2022/2/4.
 */
class BasicAreaChartView constructor(context: Context, attributeSet: AttributeSet) :
    View(context, attributeSet) {
    private val margin = 150f
    private val data = arrayListOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
    private val dataY = arrayListOf(820, 932, 901, 934, 1290, 1330, 1320)
    private val linePaint = Paint().apply {
        color = Color.GRAY
        style = Paint.Style.STROKE
        strokeWidth = 4f
    }

    init {

    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        drawScaleLine(canvas)

    }

  //绘制刻度
  private fun drawScaleLine(canvas: Canvas) {
    canvas.translate(0f, height.toFloat())
    canvas.scale(1f, -1f)
    canvas.translate(margin, margin)


    //绘制X轴的刻度
    canvas.save()
    val spaceWidth = (width - margin * 2) / (data.size-1)
    for (index in 0 until data.size) {
        //绘制X轴上刻度线
        canvas.drawLine(0f, 0f, 0f, -lineLength, linePaint)
        //通过水平变换移动画布来循环绘制刻度
        canvas.translate(spaceWidth, 0f)
    }
    //恢复画布坐标系到左下角。
    canvas.restore()

    //绘制竖直方向的线
    canvas.save()
    val spaceHeight = (height - margin * 2) / dataY.size
    for (index in 0..5) {
        //绘制平行于X轴线
        canvas.drawLine(0f, 0f, width.toFloat() - margin * 2, 0f, linePaint)
        canvas.translate(0f, spaceHeight)
    }
    canvas.restore()

   }
}
2.绘制文字

不知道小伙伴们换记得默认坐标系绘制文字默认在那个位置呢?大家可以记忆或者猜想一下。

下面代码咋们要不先来看看?

kotlin 复制代码
//绘制文字
private fun drawScaleText(canvas: Canvas) {
    canvas.save()
    canvas.drawText("Mon",0f,0f,textPaint)
    canvas.restore()
}

想必大家也清楚,一个清晰的绘制思路和方案是一个美好的开始,万事先做好分析,根据原理和提出的方案分步进行。

学习了前面章节,我们知道默认字体绘制如下图1显示,在x轴正方向上方,y轴负方向。因为我们画布坐标系是图二向上Y正,向右X正,所以文字绘制看起来是颠倒的。所以绘制时候我们只需要将坐标系沿x轴镜像反转。接下来我们可以通过字体的宽高进行平移画布到文字具体位置。如下图不难看出文字向下移动距离=刻度线长度+文字高度,向左移动文字宽度/2。

scss 复制代码
//绘制文字
private fun drawScaleText(canvas: Canvas) {
    canvas.save()
    val spaceWidth = (width - margin * 2) / (data.size-1)
    for (index in 0 until data.size) {
        val str = data[index]
        val rect = Rect()
        textPaint.getTextBounds(str,0,str.length,rect)
        canvas.save()
        canvas.scale(1f, -1f)
        canvas.translate(-rect.width()/2f,rect.height().toFloat()+lineLength)
        canvas.drawText(data[index], 0f, 0f, textPaint)
        canvas.restore()
        canvas.translate(spaceWidth,0f)
    }
    canvas.restore()
}

Y轴方向同理自己分析,画布向左移动文字的宽度+一定的距离(lineLength)。向下移动文字高度的二分之一即可。

scss 复制代码
canvas.save()
//每个刻度线之间的实际高度像素
val spaceHeight = (height - margin * 2) / dataY.size
for (index in 0 until 6) {
    val str = (300*index).toString()
    //测量文字的rect容器
    val rect = Rect()
    textPaint.getTextBounds(str,0,str.length,rect)
    canvas.save()
    canvas.scale(1f, -1f)
    //向左向下移动画布位置
    canvas.translate(-rect.width().toFloat()- lineLength,rect.height()/2f)
    canvas.drawText(str, 0f, 0f, textPaint)
    canvas.restore()
    canvas.translate(0f,spaceHeight)
}
canvas.restore()
2.绘制数据

可以看到数据刻度在Y轴最大数值yMax=1500,而实际的刻度高度mH= height-2*margin,可以计算出每一刻度所占的像素为scaleH= mH/yMax。

scss 复制代码
private fun drawData(canvas: Canvas) {
    val spaceWidth = (width - margin * 2) / (data.size - 1)
    val scaleYH = (height - margin * 2) / 1500
    val path = Path()
    path.moveTo(0f, scaleYH * dataY[0])
    for (index in 1 until dataY.size) {
        path.lineTo(spaceWidth * index, scaleYH * dataY[index].toFloat())
    }
    canvas.drawPath(path, lineDataPaint)
    //圆圈内白色部分
    for (index in 1 until dataY.size) {
        canvas.drawCircle(
            spaceWidth * index,
            scaleYH * dataY[index].toFloat(),
            6f,
            circleDataPaint
        )
    }
    //圆圈外部蓝色部分
    for (index in 0 until dataY.size) {
        canvas.drawCircle(
            spaceWidth * index,
            scaleYH * dataY[index].toFloat(),
            6f,
            lineDataPaint
        )
    }
}
ini 复制代码
绘制填充部分,之前课程都详细讲过,只需要将折线部分闭合,通过画布填充样式就可以搞定。
style = Paint.Style.FILL
scss 复制代码
//绘制闭合区域
val innerPath = Path()
innerPath.moveTo(0f, scaleYH * dataY[0])
for (index in 1 until dataY.size) {
    innerPath.lineTo(spaceWidth * index, scaleYH * dataY[index].toFloat())
}
innerPath.lineTo(spaceWidth * (dataY.size-1),0f)
innerPath.lineTo(0f,0f)
innerPath.close()
canvas.drawPath(innerPath, innerPaint)
3.增加动画
ini 复制代码
private fun drawData(canvas: Canvas) {
    val spaceWidth = (width - margin * 2) / (data.size - 1)
    val scaleYH = (height - margin * 2) / 1500

    //绘制闭合区域
    val innerPath = Path()
    innerPath.moveTo(0f, scaleYH * dataY[0])
    for (index in 1 until dataY.size) {
        innerPath.lineTo(spaceWidth * index, scaleYH * dataY[index].toFloat())
    }

    val mPathMeasure=PathMeasure(innerPath, false)
    val length:Float= mPathMeasure.length
    val stop: Float = length
    val start = 0f
    //用于截取整个path中某个片段,通过参数startD和stopD来控制截取的长度,
    // 并将截取后的path保存到参数dst中,最后一个参数表示起始点是否使用moveTo将路径的新起始点移到结果path的起始点中,
    // 通常设置为true
    val resultGreenPath=Path()
    mPathMeasure.getSegment(start, stop*(mCurAnimValue), resultGreenPath, true)
    mPathMeasure.setPath(resultGreenPath, false)
    val pos = FloatArray(2)
    mPathMeasure.getPosTan(length - 1, pos, null)

    resultGreenPath.lineTo(pos[0],0f)
    resultGreenPath.lineTo(0f,0f)
    resultGreenPath.close()
    canvas.drawPath(resultGreenPath, innerPaint)



    val path = Path()
    path.moveTo(0f, scaleYH * dataY[0])
    for (index in 1 until dataY.size) {
        path.lineTo(spaceWidth * index, scaleYH * dataY[index].toFloat())
    }
    val mPathMeasure1=PathMeasure(innerPath, false)
    val length1:Float= mPathMeasure1.length
    val stop1: Float = length1
    val start1 = 0f
    val resultGreenPath1=Path()
    mPathMeasure.getSegment(start1, stop1*(mCurAnimValue), resultGreenPath1, true)
    mPathMeasure.setPath(resultGreenPath1, false)
    val pos1 = FloatArray(2)
    mPathMeasure.getPosTan(length1 - 1, pos1, null)

    resultGreenPath1.lineTo(pos1[0],0f)
    canvas.drawPath(resultGreenPath1, lineDataPaint)
    //圆圈内白色部分
    for (index in 1 until dataY.size) {
        canvas.drawCircle(
            spaceWidth * index,
            scaleYH * dataY[index].toFloat(),
            6f,
            circleDataPaint
        )
    }
    //圆圈外部蓝色部分
    for (index in 0 until dataY.size) {
        canvas.drawCircle(
            spaceWidth * index,
            scaleYH * dataY[index].toFloat(),
            6f,
            lineDataPaint
        )
    }
}

最后测试屏幕适配是否可行

三、绘制折线堆叠图

接下来咋们来绘制Echars案例里面的折线堆叠图。

1.绘制坐标系和刻度

绘制如上面几乎一致,不做详解。但是绘制过程中大家可以试着在不同的坐标系下进行绘制,锻炼自己对于平面坐标系的理解。

scss 复制代码
package com.example.draw_android.section16_line_chart.c_stached_line_chart
/*app/src/main/java/com/example/draw_android/section16_line_chart/c_stached_line_chart/StackedLineChartView.kt*/
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Rect
import android.util.AttributeSet
import android.view.View

/**
 * Created by wangfei44 on 2022/2/6.
 */
class StackedLineChartView constructor(context: Context, attributeSet: AttributeSet) :
    View(context, attributeSet) {
    private val lineLength = 20f
    private val margin = 150f
    private val data = arrayListOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
    private val linePaint = Paint().apply {
        color = Color.argb(99, 123, 123, 123)
        style = Paint.Style.STROKE
        strokeWidth = 3f
        isAntiAlias = true
    }
    private val lineDataPaint = Paint().apply {
        color = Color.argb(222, 123, 123, 223)
        style = Paint.Style.STROKE
        strokeWidth = 5f
        isAntiAlias = true
    }
    private val circleDataPaint = Paint().apply {
        color = Color.WHITE
        style = Paint.Style.FILL
        strokeWidth = 5f
        isAntiAlias = true
    }
    private val textPaint = Paint().apply {
        color = Color.argb(242, 123, 123, 123)
        style = Paint.Style.FILL
        strokeWidth = 4f
        textSize = 24f
        isAntiAlias = true
    }

    init {

    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        drawScaleLine(canvas)
        drawScaleText(canvas)
    }

    //绘制刻度
    private fun drawScaleLine(canvas: Canvas) {
        canvas.translate(0f, height.toFloat())
        canvas.scale(1f, -1f)
        canvas.translate(margin, margin)


        //绘制X轴的刻度
        canvas.save()
        val spaceWidth = (width - margin * 2) / (data.size - 1)
        for (index in 0 until data.size) {
            //绘制X轴上刻度线
            canvas.drawLine(0f, 0f, 0f, -lineLength, linePaint)
            //通过水平变换移动画布来循环绘制刻度
            canvas.translate(spaceWidth, 0f)
        }
        //恢复画布坐标系到左下角。
        canvas.restore()

        //绘制竖直方向的线
        canvas.save()
        val spaceHeight = (height - margin * 2) / 5
        for (index in 0..5) {
            //绘制平行于X轴线
            canvas.drawLine(0f, 0f, width.toFloat() - margin * 2, 0f, linePaint)
            canvas.translate(0f, spaceHeight)
        }
        canvas.restore()

    }

    //绘制文字
    private fun drawScaleText(canvas: Canvas) {
        canvas.save()
        val spaceWidth = (width - margin * 2) / (data.size - 1)
        for (index in 0 until data.size) {
            val str = data[index]
            val rect = Rect()
            textPaint.getTextBounds(str, 0, str.length, rect)
            canvas.save()
            canvas.scale(1f, -1f)
            canvas.translate(-rect.width() / 2f, rect.height().toFloat() + lineLength)
            canvas.drawText(data[index], 0f, 0f, textPaint)
            canvas.restore()
            canvas.translate(spaceWidth, 0f)
        }
        canvas.restore()

        canvas.save()
        val spaceHeight = (height - margin * 2) / 6
        for (index in 0 until 7) {
            val str = (500 * index).toString()
            val rect = Rect()
            textPaint.getTextBounds(str, 0, str.length, rect)
            canvas.save()
            canvas.scale(1f, -1f)
            canvas.translate(-rect.width().toFloat() - lineLength, rect.height() / 2f)
            canvas.drawText(str, 0f, 0f, textPaint)
            canvas.restore()
            canvas.translate(0f, spaceHeight)
        }
        canvas.restore()
    }
}
2.绘制数据

数据是模拟的,不必纠结数据一致,当然这里颜色可以根据高度来设置,我这里为了方便数据源默认给了颜色。

less 复制代码
模拟数据:
private val data = arrayListOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
private val dataArray = arrayListOf(
    Series(
        "Email",
        "line",
        "Total",
        arrayListOf(120, 132, 101, 134, 90, 230, 210),
        Color.argb(255, 141, 192, 240)
    ),
    Series(
        "Union Ads",
        "line",
        "Total",
        arrayListOf(350, 332, 201, 294, 290, 330, 410),
        Color.argb(255, 217, 131, 131)

    ),
    Series(
        "Video Ads",
        "line",
        "Total",
        arrayListOf(820, 890, 790, 800, 900, 1300, 1400),
        Color.argb(255, 237, 189, 96)

    ),
    Series(
        "Direct",
        "line",
        "Total",
        arrayListOf(500, 560, 500, 540, 530, 800, 910),
        Color.argb(255, 181, 213, 194)
    ),
    Series(
        "Search Engine",
        "line",
        "Total",
        arrayListOf(1640, 1860, 1802, 1868, 2580, 2660, 2640),
        Color.argb(255, 119, 135, 194)
    )
)
data class Series(
    val name: String,
    val type: String,
    val stack: String,
    val data: ArrayList<Int>,
    val color: Int
)

绘制过程

ini 复制代码
private fun drawData(canvas: Canvas) {
    //水平每个刻度之间的间距
    val spaceWidth = (width - margin * 2) / (data.size - 1)
    //计算每一个单位真实的实际像素高度
    val scaleHeight = (height - margin * 2) / 3000
    //循环绘制数据线条
    for (index in 0 until dataArray.size) {
        val dataList = dataArray[index].data
        val path = Path()
        for (ind in 0 until dataList.size) {
            if (ind == 0) {
                path.moveTo(ind * spaceWidth, dataList[ind] * scaleHeight)
            } else {
                path.lineTo(ind * spaceWidth, dataList[ind] * scaleHeight)
            }
        }
        canvas.drawPath(path, linePaint.apply {
            color = dataArray[index].color
        })
    }
    //这里进行绘制线上的点圆圈,和画线分开保证覆盖在线上面。
    for (index in 0 until dataArray.size) {
        val dataList = dataArray[index].data
        for (ind in 0 until dataList.size) {
            if (ind == 0) {
                canvas.drawCircle(
                    ind * spaceWidth,
                    dataList[ind] * scaleHeight,
                    6f,
                    innerCirclePaint.apply {
                        color = Color.WHITE
                    })
                canvas.drawCircle(
                    ind * spaceWidth,
                    dataList[ind] * scaleHeight,
                    4f,
                    circlePaint.apply {
                        color = dataArray[index].color
                    })
            } else {
                canvas.drawCircle(
                    ind * spaceWidth,
                    dataList[ind] * scaleHeight,
                    6f,
                    innerCirclePaint.apply {
                        color = Color.WHITE
                    })
                canvas.drawCircle(
                    ind * spaceWidth,
                    dataList[ind] * scaleHeight,
                    4f,
                    circlePaint.apply {
                        color = dataArray[index].color
                    })
            }
        }
    }

}
3.添加手势交互
makefile 复制代码
由下图分析:
1.当鼠标移动过程距离最近的折线转折点方向连成一条竖直虚线。
2.在移动过程中鼠标右下方有一个显示详情的小框。
3.移动竖直方向和折线交汇的圆点有缩放动画。

Echars中的动画部分

前面课程对于手势,手势坐标系与画布坐标系映射变换都讲过了。所以这里咋们直接写代码,过程会伴随反复讲解过的图文描述讲解。

css 复制代码
配合下图分析过程:
手势移动过程中,我们知道手势坐标系是默认屏幕左上角为圆点。而我们的绘制坐标系在左下角,且向右边进行了平移margin的
画布变换。
1.我们配合下图来再次讲解手势坐标系与画布坐标系映射,图中X,Y坐标系代表手势坐标系,X1,Y1坐标系代表画布绘制坐标系。
我们可以计算手势坐标映射到画布坐标系,图中圆点width = event.x-margin,height = 画布的高度-event.y-margin。
2.我们可以看到手势移动过程中,根据判断测量最近位置的折线折点来进行绘制竖直方向的竖线。我们可以循环遍历每根刻度距离
手势当前的位置,取最近的刻度进行绘制竖直方向的虚线。
kotlin 复制代码
    private var minValue: Float = 0f
    private var recentIndex: Int = 0
    private var visible: Boolean = false
    private val recentPaint = Paint().apply {
        color = Color.argb(200, 123, 223, 136)
        style = Paint.Style.STROKE
        strokeWidth = 3f
        isAntiAlias = true
    }

    private fun drawLine(canvas: Canvas) {
        val spaceWidth = (width - margin * 2) / (data.size - 1)
        val minMarginLeft = recentIndex * spaceWidth
        if (visible) {
            canvas.drawLine(minMarginLeft, 0f, minMarginLeft, height - margin * 2, recentPaint)
        }
    }

    override fun onTouchEvent(event: MotionEvent): Boolean {
        when (event.action) {
            MotionEvent.ACTION_DOWN -> {
                visible = true
                invalidate()
            }
            MotionEvent.ACTION_MOVE -> {
                //水平方向每个刻度之间的实际像素距离
                val spaceWidth = (width - margin * 2) / (data.size - 1)
                //默认初始化最小距离折线点的距离为手势映射到画布坐标系的距离
                minValue = event.x - margin
                //遍历每个刻度比较那个距离手势最近。获取最近距离的折线点
                for (index in 0 until data.size) {
                    //event.x - margin为手势坐标映射到画布坐标系之后的位置。这里取绝对值,我们根据观察在最近的左右只要最近就会出现线。
                    if (abs(event.x - margin - spaceWidth * index) <= minValue) {
                        minValue = abs(event.x - margin - spaceWidth * index)
                        recentIndex = index
                    }
                }
                //循环获取来距离最近的刻度,然后通过invalidate取刷新当然这里为了方便全局刷新了。可以局部刷新为了性能。
                invalidate()
            }
            MotionEvent.ACTION_UP -> {
                visible = false
                invalidate()
            }
        }
        return true

    }

我们来看看效果再继续

4.完善动画和适配测试

通过visible和recentIndex来完善当前手势竖线上的折点圆动画。可以通过绘制圆的半径来实现。

ini 复制代码
//这里进行绘制线上的点圆圈,和画线分开保证覆盖在线上面。
for (index in 0 until dataArray.size) {
    val dataList = dataArray[index].data
    for (ind in 0 until dataList.size) {
        if (ind == 0) {
            canvas.drawCircle(
                ind * spaceWidth,
                dataList[ind] * scaleHeight,
                if (visible&&ind == recentIndex) {
                    8f
                } else {
                    6f
                },
                innerCirclePaint.apply {
                    color = Color.WHITE
                })
            canvas.drawCircle(
                ind * spaceWidth,
                dataList[ind] * scaleHeight,
                if (visible&&ind == recentIndex) {
                    6f
                } else {
                    4f
                },
                circlePaint.apply {
                    color = dataArray[index].color
                })
        } else {
            canvas.drawCircle(
                ind * spaceWidth,
                dataList[ind] * scaleHeight,
                if (visible&&ind == recentIndex) {
                    8f
                } else {
                    6f
                },
                innerCirclePaint.apply {
                    color = Color.WHITE
                })
            canvas.drawCircle(
                ind * spaceWidth,
                dataList[ind] * scaleHeight,
                if (visible&&ind == recentIndex) {
                    6f
                } else {
                    4f
                },
                circlePaint.apply {
                    color = dataArray[index].color
                })
        }
    }
}

四、总结

前面章节学过了画布的变换画布状态的堆栈保存,也学过了手势坐标系画布坐标系的相互映射关系,剩下的就是不厌其烦的不断的练习,不断的熟悉API的使用,才能熟能生巧,胸有成竹,形成自己自定义绘制的架构知识体系。

折线图系列在Echars里有很多案例,当然后面有时间都会补充,或者大家可以留言,上图,我会挑选一些比较有代表性和难度的进行绘制。这篇打算多写几个的,但是篇幅太长了,怕影响阅读。

相关推荐
v(kaic_kaic)25 分钟前
基于STM32热力二级管网远程监控系统设计(论文+源码)_kaic
android·数据库·学习·mongodb·微信·目标跟踪·小程序
奋斗音音1 小时前
Android Studio :The emulator process for AVD was killed。
android·ide·android studio
海伟1 小时前
kotlin flow 使用
android·开发语言·kotlin
柴可夫司机i5 小时前
.NET MAUI(.NET Multi-platform App UI)下拉选框控件
android·.net·visual studio·xamarin
zhangphil6 小时前
Android PopupWindow.showAsDropDown报错:BadTokenException: Unable to add window
android
16seo6 小时前
Android使用RecyclerView仿美团分类界面
android·gitee
前端物语6 小时前
深入解析 Android 的 evaluateJavascript
android·java·前端
峥嵘life7 小时前
Android 热点分享二维码功能简单介绍
android
柴可夫司机i8 小时前
.NET MAUI(.NET Multi-platform App UI)上下文菜单
android·.net·visual studio·xamarin
M_灵均11 小时前
MySQL面试知识汇总
android·mysql·面试