Android技术知识Android开发Android开发经验谈

图表CanvasChartView(五):扩展CanvasCha

2018-06-20  本文已影响35人  珠穆朗玛小王子

前言

经过前几篇的努力,我们的CanvasChartView已经变得很健壮,今天我们扩展几个小功能,并且增加自定义属性,为发布CanvasChartView,做最后的收尾工作。

要添加的功能列表:

正文

首先一张图了解一下今天的任务:

image

数据点之间的连线增加曲线样式

直接贴出关键代码:

   /**
     * 图表的线条样式
     *
     * */
    private var chartLineStyle: ChartLineStyle = ChartLineStyle.LINEAR

    /**
     * 计算数据之间的连线和数据点
     * */
    private fun addItemLine(canvas: Canvas, data: List<ChartBean>, path: Path, dotPath: Path) {
       ...
       if (index == startIndex) {
           path.moveTo(xPos, yPos)
       } else {
           // 直线
           if (chartLineStyle == ChartLineStyle.LINEAR) {
              path.lineTo(xPos, yPos)
           }
           // 曲线
           else if (chartLineStyle == ChartLineStyle.CURVE) {
               curveTo(index, data, startIndex, xPos, yPos, path)
           }
       }
        index++
       ...
    }

   /**
     * 平滑到下一个点
     * */
    private fun curveTo(index: Int, data: List<ChartBean>, startIndex: Int, xPos: Float, yPos: Float, path: Path) {
        // 如果是最后一个点,不需要计算
        if (index + 1 >= data.size) {
            return
        }
        // 结束的点
        val nextXPos = calculateXPosition(startIndex, index + 1)
        val nextYPos = calculateYPosition(data[index + 1])
        val wt = xPos + markWidth / 2
        path.cubicTo(wt, yPos, wt, nextYPos, nextXPos, nextYPos)
    }

  /**
     * 线条Style
     * */
    enum class ChartLineStyle(val value: String) {

        /**
         * 直线
         * */
        LINEAR("linear"),

        /**
         * 曲线
         * */
        CURVE("curve")

    }

主要是Path.cubicTo方法来实现曲线,这里就不详细介绍了,大家可以去百度一下资料。

新增图表只绘制第一象限(x, y都为正数)

大部分我们需要的图表都是正数的,例如温度,收入,支出等等,所以增加只绘制第一象限的样式,还是有些必要的。直接看代码:

/**
     * 是否只显示第一象限
     * */
    private var onlyFirstArea: Boolean = false

/**
     * 画X轴
     * */
    private fun drawXLine(canvas: Canvas) {
        val width = width.toFloat()
        if (onlyFirstArea) {
            // 绘制X轴
            canvas.drawLine(getRealX(0f), getRealY(height - lineWidth / 2), width, getRealY(height - lineWidth / 2), paint)
        } else {
            // 计算y方向上的中心位置
            val yCenter = (height - lineWidth) / 2
            // 绘制X轴
            canvas.drawLine(getRealX(0f), getRealY(yCenter), width, getRealY(yCenter), paint)
        }
    }

/**
     * 计算每一个数据点在Y轴上的坐标
     * */
    private fun calculateYPosition(value: ChartBean): Float {
        // 计算比例
        val scale = value.number / yLineMax
        // 计算y方向上的中心位置
        val yCenter = if (onlyFirstArea) {
            height - lineWidth
        } else {
            (height - lineWidth) / 2
        }
        // 如果小于0
        return yCenter - yCenter * scale
    }

主要是修改绘制X轴的位置:由原来的高度的中间位置改为最底部,其他没有变化。

getRealX和getRealY方法,是对传进来的X,Y坐标进行加工的方法,因为之后增加了刻度文字以后,整个图表的绘制位置要右移,所以提前封装好这个方法,便于之后的修改。

绘制X,Y坐标轴的刻度文字

最后绘制坐标轴的文字,也是做复杂的一步,首先我们画一张简单的逻辑图,帮助我们弄清实现的思路:

image

有了这张图作为参考,我们需要准备以下工作:

ok,接下来开始修改我们的代码。

首先修改BaseScroller的代码:

/**
     * 绘制Y轴的偏移值,这个值用来绘制Y轴的文字
     * */
    protected var drawOffsetX = 0f

    /**
     * 绘制X轴的偏移值,这个值用来绘制X轴下面的文字
     * */
    protected var drawOffsetY = 0f

/**
     * 把计算的X坐标加上偏移值
     * */
    protected fun getRealX(xPos: Float): Float {
        return xPos + drawOffsetX
    }

    /**
     * 把计算的Y坐标加上偏移值
     */
    protected fun getRealY(yPos: Float): Float {
        return yPos - drawOffsetY
    }

首先定义变量保存偏移值的值,然后修改所有跟偏移值有关的计算,例如计算检查边界、canvas偏移的值等等,随便贴出一个例子:

/**
     * 根据偏移值,计算绘制的数据的开始位置
     * */
    protected fun getDataStartIndex(): Int {
        // 计算已经偏移了几个刻度
        val index = if (dataDotGravity == DataDotGravity.CENTER) {
            (offsetX - drawOffsetX - markWidth / 2) / (markWidth)
        } else {
            (offsetX - drawOffsetX - markWidth) / (markWidth)
        }
        return if (index < 0) {
            0
        } else {
            index.toInt()
        }
    }

在计算开始位置的时候,要减去x方向的偏移值,才能得到正确的index。

然后修改onDraw()方法,计算drawOffsetX和drawOffsetY:

override fun onDraw(canvas: Canvas) {
        ......
        canvas.save()
        // 如果要绘制刻度值的文字,需要先计算图标的偏移距离
        // y轴刻度值文字的最大宽度
        if (showMarkText) {
            paint.textSize = markTextSize
            xMarkTextMaxWidth = getTextWidth(yLineMax.toString())
            // x轴绘制的起始位置偏移值
            drawOffsetX = xMarkTextMaxWidth
            // y轴绘制的起始位置偏移值
            val fontMetrics = paint.fontMetrics
            drawOffsetY = fontMetrics.bottom - fontMetrics.top
        }
        // 这里要重置一下缓存,因为要开始绘制新的图标了
        pathCacheManager.resetCache()
        ......
    }

drawOffsetX等于刻度Y轴文字的宽度,drawOffsetY等于x轴文字的高度。

最后画出刻度的文字:

/**
     * 绘制Y轴的文字
     * */
    private fun drawYMarkText(canvas: Canvas, index: Int, yLineSpace: Float) {
        if (!showMarkText) {
            return
        }
        // 设置画笔的效果
        paint.color = markTextColor
        paint.textSize = markTextSize
        paint.pathEffect = null
        paint.style = Paint.Style.FILL
        val text = (yLineMax - index * yLineMax / yLineMarkCount).toString()
        canvas.drawText(text,
                0f, getRealY(height.toFloat()) / yLineMarkCount * index + yLineSpace,
                paint)
    }

首先绘制了Y轴的文字,按照最大刻度的百分比作为刻度的文字,例如现在最大是100,所以每一个刻度就是:0,20,,40,60,80,100。

X州的刻度文字跟Y轴不同,因为Y轴的文字是固定的,X轴的文字是随着滑动的距离而变化的,我们之前已经绘制了数据点的文字,所以这里直接在原有的基础上扩展:

/**
     * 绘制文字
     *
     * */
    private fun drawText(canvas: Canvas, item: ChartBean, xPos: Float, yPos: Float) {
        // 绘制数据点的文字
        ......
        // 绘制刻度值的文字
        // 设置画笔的效果
        if (showMarkText) {
            paint.color = markTextColor
            paint.textSize = markTextSize
            paint.style = Paint.Style.FILL
            val markTextWidth = getTextWidth(item.markText)
            // 文字绘制开始的位置是基线位置,所以要把上移paint.fontMetrics.bottom距离,才能显示完整的文字
            canvas.drawText(item.markText, xPos - markTextWidth / 2, height.toFloat() - paint.fontMetrics.bottom, paint)
        }
    }

最后千万别忘了锁住我们绘制的canvas区域,否则图表的绘制就与Y轴刻度文字重叠了:

override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        ......
        // 绘制X轴和Y轴
        drawXYLine(canvas)
        // 绘制X轴的虚线
        drawXDashLine(canvas)
        // 从这里开始,我们要对canvas进行偏移
        canvas.translate(getCanvasOffset() + lineWidth, -lineWidth)
        // 裁剪要绘制的区域
        // 裁剪的区域坐标记得减去偏移值,修正裁剪的位置
        canvas.clipRect(getRealX(lineWidth - getCanvasOffset()), 0f, width.toFloat() - getCanvasOffset(), height.toFloat())
        // 绘制每一条数据之间的间隔虚线
        drawYDashLine(canvas)
        // 绘制数据
        drawData(canvas)
        // 恢复一下canvas的状态
        canvas.restore()
    }

从代码上看,我们Y轴刻度文字的右侧的部分设置成需要绘制的区域,这样图表只会在这一部分进行绘制,也就解决了重叠的问题,坐标轴刻度文字到这里也完美解决。

自定义属性

自定义属性是基础中的基础,今天我们来复习一下自定义属性的用法:

首先在res/values文件夹中创建attrs.xml文件,attrs用于定义自定义属性:

image

打开attrs.xml文件,写下我们需要的属性:

<?xml version="1.0" encoding="utf-8"?>
<resources>

    <!-- BaseScrollerView的自定义属性 -->
    <declare-styleable name="BaseScrollerView">
        <!-- x轴显示的刻度的个数 -->
        <attr name="xLineMarkCount" format="integer" />
        <!-- y轴显示的刻度的个数 -->
        <attr name="yLineMarkCount" format="integer" />
        <!-- X轴和Y轴的宽度 -->
        <attr name="lineWidth" format="dimension" />
        <!-- 数据圆点的位置 -->
        <attr name="dataDotGravity" format="enum" >
            <enum name="line" value="0" />
            <enum name="center" value="1" />
        </attr>
    </declare-styleable>

    <!-- CanvasChartView的自定义属性 -->
    <declare-styleable name="CanvasChartView">
        <!-- 是否只显示第一象限 -->
        <attr name="onlyFirstArea" format="boolean" />
        <!-- x、y轴的颜色 -->
        <attr name="lineColor" format="color" />
        <!-- 图表的连线颜色 -->
        <attr name="chartLineColor" format="color" />
        <!-- 图表的连线宽度-->
        <attr name="chartLineWidth" format="dimension" />
        <!-- 图表的连线样式:直线或曲线 -->
        <attr name="chartLineStyle" format="enum">
            <enum name="linear" value="0" />
            <enum name="curve" value="1" />
        </attr>
        <!-- 圆点的宽度 -->
        <attr name="dotWidth" format="dimension" />
        <!-- 圆点的颜色 -->
        <attr name="dotColor" format="color" />
        <!-- 是否显示圆点 -->
        <attr name="showDataDot" format="boolean" />
        <!-- 虚线的颜色 -->
        <attr name="dashLineColor" format="color" />
        <!-- 虚线的宽度 -->
        <attr name="dashLineWidth" format="dimension" />
        <!-- y轴的最大刻度 -->
        <attr name="yLineMax" format="integer" />
        <!-- 绘制文字的大小 -->
        <attr name="textSize" format="dimension" />
        <!-- 绘制文字的颜色 -->
        <attr name="textColor" format="color" />
        <!-- 文字和圆点之间的间距 -->
        <attr name="textSpace" format="dimension" />
        <!-- 是否显示刻度文字 -->
        <attr name="showMarkText" format="boolean"/>
        <!-- 刻度文字大小 -->
        <attr name="markTextSize" format="dimension"/>
        <!-- 刻度文字颜色 -->
        <attr name="markTextColor" format="color"/>
    </declare-styleable>

</resources>

自定属性有几个要注意的地方:

然后在我们的布局xml中使用自定义属性:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".MainActivity">

    <com.lzp.com.canvaschart.view4.CanvasChartView
        android:id="@+id/canvas_chart_4"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:chartLineColor="@color/colorPrimary"
        app:chartLineStyle="curve"
        app:dashLineColor="@color/colorPrimaryDark"
        app:dataDotGravity="center"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:lineColor="@color/colorAccent"
        app:onlyFirstArea="true"
        app:showDataDot="true"
        app:showMarkText="true" />

</LinearLayout>

需要在使用自定义View的外层父布局(一般是根布局)中引入自定义属性

xmlns:app="http://schemas.android.com/apk/res-auto"

这个格式是固定的,app是自定义属性的命名空间,可以随便定义,一般设置为app。

这样View就可以使用自定义属性了。

最后就是得到xml中设置的自定义属性的值:

init {
        val typedArray = context.obtainStyledAttributes(attributes, R.styleable.BaseScrollerView)
        // 绘制X轴和Y轴的宽度
        lineWidth = typedArray.getDimensionPixelSize(R.styleable.BaseScrollerView_lineWidth, 5).toFloat()
        // 得到x轴的刻度数
        xLineMarkCount = typedArray.getInt(R.styleable.BaseScrollerView_xLineMarkCount, 5)
        // 得到y轴的刻度数
        yLineMarkCount = typedArray.getInt(R.styleable.BaseScrollerView_yLineMarkCount, 5)
        // 得到绘制数据点的位置
        dataDotGravity = if (typedArray.getInt(R.styleable.BaseScrollerView_dataDotGravity, 0) == 0) {
            DataDotGravity.LINE
        } else {
            DataDotGravity.CENTER
        }
        typedArray.recycle()
    }

我使用的Kotlin,Kotlin的init方法是在构造方法中执行的,并且可以直接使用构造方法中的参数,如果是Java,可以直接在构造方法中完成。

从构造方法中的Attributes参数,取出自定义属性集合BaseScrollerView,之后根据匹配的类型一次得到值就可以了,最需要注意的是,在初始化结束后,记得调用TypedArray.recycle()方法,释放内存空间,否则会造成内存泄漏。

总结

到此为止我们的CanvasChartView功能已经非常丰富了,但是在实际开发中图表样式往往都是风格迥异,很多时候都需要自己去自定义或者根据一些相对成熟的开源库进行二次修改,所以为了摆脱产品的蹂躏,掌握绘制图表的技能真的是非常非常必要的。

最后一篇让我们一起把CanvasChartView发布到jcenter上。

github下载地址

上一篇下一篇

猜你喜欢

热点阅读