Android自定义折线图控件的完整步骤

发布时间:2023-02-17 13:30

目录
  • 前言
  • 概述
  • 原点
    • 计算Y轴宽度
    • 计算X轴高度
  • X轴
    • 绘制轴线
    • X轴刻度间隔
    • 网格线、文本
  • Y轴
    • 计算Y轴分布
    • 刻度间隔、网格线、文本
  • 折线
    • 代码
      • 总结

        前言

        日前,有一个“折现图”的需求,如下图所示:

        \"Android自定义折线图控件的完整步骤_第1张图片\"

        概述

        如何自定义折线图?首先将折线图的绘制部分拆分成三部分:

        • 原点
        • X轴
        • Y轴
        • 折线

        原点

        第一步,需要定义出“折线图”原点的位置,由图得:

        \"Android自定义折线图控件的完整步骤_第2张图片\"

        可以发现,原点的位置由X轴、Y轴所占空间决定:

        OriginX:Y轴宽度
        OriginY:View高度 - X轴高度

        计算Y轴宽度

        思路:遍历Y轴的绘制文字,用画笔测量其最大宽度,在加上其左右Margin间距即Y轴宽度

        Y轴宽度 = Y轴MarginLeft + Y轴最大文字宽度 + Y轴MariginRight

        计算X轴高度

        思路:获取X轴画笔FontMetrics,根据其top、bottom计算出X轴文字高度,在加上其上下Margin间距即X轴高度

        val fontMetrics = xAxisTextPaint.fontMetrics
        val lineHeight = fontMetrics.bottom - fontMetrics.top
        xAxisHeight = lineHeight + xAxisOptions.textMarginTop + xAxisOptions.textMarginBottom

        X轴

        第二步,根据原点位置,绘制X轴轴线、网格线、文本

        绘制轴线

        绘制轴线比较简单,沿原点向控件右侧画一条直线即可

        if (xAxisOptions.isEnableLine) {
            xAxisLinePaint.strokeWidth = xAxisOptions.lineWidth
            xAxisLinePaint.color = xAxisOptions.lineColor
            xAxisLinePaint.pathEffect = xAxisOptions.linePathEffect
            canvas.drawLine(originX, originY, width.toFloat(), originY, xAxisLinePaint)
        }

        X轴刻度间隔

        在绘制网格线、文本之前需要先计算X轴的刻度间隔:

        \"Android自定义折线图控件的完整步骤_第3张图片\"

        这里处理的方式比较随意,直接将X轴等分7份即可(因为需要显示近7天的数据)

        xGap = (width - originX) / 7

        网格线、文本

        网格线:只需要根据X轴的刻度,沿Y轴方向依次向控件顶部,画直线即可

        文本:文本需要通过画笔,提前测量出待绘制文本的区域,然后计算出居中位置绘制即可

        xAxisTexts.forEachIndexed { index, text ->
            val pointX = originX + index * xGap
            //刻度线
            if (xAxisOptions.isEnableRuler) {
                xAxisLinePaint.strokeWidth = xAxisOptions.rulerWidth
                xAxisLinePaint.color = xAxisOptions.rulerColor
                canvas.drawLine(
                    pointX, originY,
                    pointX, originY - xAxisOptions.rulerHeight,
                    xAxisLinePaint
                )
            }
            //网格线
            if (xAxisOptions.isEnableGrid) {
                xAxisLinePaint.strokeWidth = xAxisOptions.gridWidth
                xAxisLinePaint.color = xAxisOptions.gridColor
                xAxisLinePaint.pathEffect = xAxisOptions.gridPathEffect
                canvas.drawLine(pointX, originY, pointX, 0f, xAxisLinePaint)
            }
            //文本
            bounds.setEmpty()
            xAxisTextPaint.textSize = xAxisOptions.textSize
            xAxisTextPaint.color = xAxisOptions.textColor
            xAxisTextPaint.getTextBounds(text, 0, text.length, bounds)
            val fm = xAxisTextPaint.fontMetrics
            val fontHeight = fm.bottom - fm.top
            val fontX = originX + index * xGap + (xGap - bounds.width()) / 2f
            val fontBaseline = originY + (xAxisHeight - fontHeight) / 2f - fm.top
            canvas.drawText(text, fontX, fontBaseline, xAxisTextPaint)
        }

        Y轴

        第三步:根据原点位置,绘制Y轴轴线、网格线、文本

        计算Y轴分布

        个人认为,这里是自定义折线图的一个难点,这里经过查阅资料,使用该文章中的算法:

        基于魔数数组的坐标轴刻度取值算法

        /**
         * 根据Y轴最大值、数量获取Y轴的标准间隔
         */
        private fun getYInterval(maxY: Int): Int {
            val yIntervalCount = yAxisCount - 1
            val rawInterval = maxY / yIntervalCount.toFloat()
            val magicPower = floor(log10(rawInterval.toDouble()))
            var magic = 10.0.pow(magicPower).toFloat()
            if (magic == rawInterval) {
                magic = rawInterval
            } else {
                magic *= 10
            }
            val rawStandardInterval = rawInterval / magic
            val standardInterval = getStandardInterval(rawStandardInterval) * magic
            return standardInterval.roundToInt()
        }
        
        /**
         * 根据初始的归一化后的间隔,转化为目标的间隔
         */
        private fun getStandardInterval(x: Float): Float {
            return when {
                x <= 0.1f -> 0.1f
                x <= 0.2f -> 0.2f
                x <= 0.25f -> 0.25f
                x <= 0.5f -> 0.5f
                x <= 1f -> 1f
                else -> getStandardInterval(x / 10) * 10
            }
        }

        刻度间隔、网格线、文本

        Y轴的轴线、网格线、文本剩下的内容与X轴的处理方式几乎一致

        //绘制Y轴
        //轴线
        if (yAxisOptions.isEnableLine) {
            yAxisLinePaint.strokeWidth = yAxisOptions.lineWidth
            yAxisLinePaint.color = yAxisOptions.lineColor
            yAxisLinePaint.pathEffect = yAxisOptions.linePathEffect
            canvas.drawLine(originX, 0f, originX, originY, yAxisLinePaint)
        }
        yAxisTexts.forEachIndexed { index, text ->
            //刻度线
            val pointY = originY - index * yGap
            if (yAxisOptions.isEnableRuler) {
                yAxisLinePaint.strokeWidth = yAxisOptions.rulerWidth
                yAxisLinePaint.color = yAxisOptions.rulerColor
                canvas.drawLine(
                    originX,
                    pointY,
                    originX + yAxisOptions.rulerHeight,
                    pointY,
                    yAxisLinePaint
                )
            }
            //网格线
            if (yAxisOptions.isEnableGrid) {
                yAxisLinePaint.strokeWidth = yAxisOptions.gridWidth
                yAxisLinePaint.color = yAxisOptions.gridColor
                yAxisLinePaint.pathEffect = yAxisOptions.gridPathEffect
                canvas.drawLine(originX, pointY, width.toFloat(), pointY, yAxisLinePaint)
            }
            //文本
            bounds.setEmpty()
            yAxisTextPaint.textSize = yAxisOptions.textSize
            yAxisTextPaint.color = yAxisOptions.textColor
            yAxisTextPaint.getTextBounds(text, 0, text.length, bounds)
            val fm = yAxisTextPaint.fontMetrics
            val x = (yAxisWidth - bounds.width()) / 2f
            val fontHeight = fm.bottom - fm.top
            val y = originY - index * yGap - fontHeight / 2f - fm.top
            canvas.drawText(text, x, y, yAxisTextPaint)
        }

        折线

        折线的连接,这里使用的是Path,将一个一个坐标点连接,最后将Path绘制,就形成了图中的折线图

        //绘制数据
        path.reset()
        points.forEachIndexed { index, point ->
            val x = originX + index * xGap + xGap / 2f
            val y = originY - (point.yAxis.toFloat() / yAxisMaxValue) * (yGap * (yAxisCount - 1))
            if (index == 0) {
                path.moveTo(x, y)
            } else {
                path.lineTo(x, y)
            }
            //圆点
            circlePaint.color = dataOptions.circleColor
            canvas.drawCircle(x, y, dataOptions.circleRadius, circlePaint)
        }
        pathPaint.strokeWidth = dataOptions.pathWidth
        pathPaint.color = dataOptions.pathColor
        canvas.drawPath(path, pathPaint)

        值得注意的是:坐标点X根据间隔是相对确定的,而坐标点Y则需要进行百分比换算

        代码

        折线图LineChart

        package com.vander.pool.widget.linechart
        import android.content.Context
        import android.graphics.*
        import android.text.TextPaint
        import android.util.AttributeSet
        import android.view.View
        import java.text.DecimalFormat
        import kotlin.math.floor
        import kotlin.math.log10
        import kotlin.math.pow
        import kotlin.math.roundToInt
        class LineChart : View {
            private var options = ChartOptions()
            /**
             * X轴相关
             */
            private val xAxisTextPaint = TextPaint(Paint.ANTI_ALIAS_FLAG)
            private val xAxisLinePaint = Paint(Paint.ANTI_ALIAS_FLAG)
            private val xAxisTexts = mutableListOf()
            private var xAxisHeight = 0f
            /**
             * Y轴相关
             */
            private val yAxisTextPaint = TextPaint(Paint.ANTI_ALIAS_FLAG)
            private val yAxisLinePaint = Paint(Paint.ANTI_ALIAS_FLAG)
            private val yAxisTexts = mutableListOf()
            private var yAxisWidth = 0f
            private val yAxisCount = 5
            private var yAxisMaxValue: Int = 0
            /**
             * 原点
             */
            private var originX = 0f
            private var originY = 0f
            private var xGap = 0f
            private var yGap = 0f
            /**
             * 数据相关
             */
            private val pathPaint = Paint(Paint.ANTI_ALIAS_FLAG).also {
                it.style = Paint.Style.STROKE
            }
            private val circlePaint = Paint(Paint.ANTI_ALIAS_FLAG).also {
                it.color = Color.parseColor(\"#79EBCF\")
                it.style = Paint.Style.FILL
            }
            private val points = mutableListOf()
            private val bounds = Rect()
            private val path = Path()
            constructor(context: Context)
                    : this(context, null)
            constructor(context: Context, attrs: AttributeSet?)
                    : this(context, attrs, 0)
            constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) :
                    super(context, attrs, defStyleAttr)
            override fun onDraw(canvas: Canvas) {
                super.onDraw(canvas)
                if (points.isEmpty()) return
                val xAxisOptions = options.xAxisOptions
                val yAxisOptions = options.yAxisOptions
                val dataOptions = options.dataOptions
                //设置原点
                originX = yAxisWidth
                originY = height - xAxisHeight
                //设置X轴Y轴间隔
                xGap = (width - originX) / points.size
                //Y轴默认顶部会留出一半空间
                yGap = originY / (yAxisCount - 1 + 0.5f)
                //绘制X轴
                //轴线
                if (xAxisOptions.isEnableLine) {
                    xAxisLinePaint.strokeWidth = xAxisOptions.lineWidth
                    xAxisLinePaint.color = xAxisOptions.lineColor
                    xAxisLinePaint.pathEffect = xAxisOptions.linePathEffect
                    canvas.drawLine(originX, originY, width.toFloat(), originY, xAxisLinePaint)
                }
                xAxisTexts.forEachIndexed { index, text ->
                    val pointX = originX + index * xGap
                    //刻度线
                    if (xAxisOptions.isEnableRuler) {
                        xAxisLinePaint.strokeWidth = xAxisOptions.rulerWidth
                        xAxisLinePaint.color = xAxisOptions.rulerColor
                        canvas.drawLine(
                            pointX, originY,
                            pointX, originY - xAxisOptions.rulerHeight,
                            xAxisLinePaint
                        )
                    }
                    //网格线
                    if (xAxisOptions.isEnableGrid) {
                        xAxisLinePaint.strokeWidth = xAxisOptions.gridWidth
                        xAxisLinePaint.color = xAxisOptions.gridColor
                        xAxisLinePaint.pathEffect = xAxisOptions.gridPathEffect
                        canvas.drawLine(pointX, originY, pointX, 0f, xAxisLinePaint)
                    }
                    //文本
                    bounds.setEmpty()
                    xAxisTextPaint.textSize = xAxisOptions.textSize
                    xAxisTextPaint.color = xAxisOptions.textColor
                    xAxisTextPaint.getTextBounds(text, 0, text.length, bounds)
                    val fm = xAxisTextPaint.fontMetrics
                    val fontHeight = fm.bottom - fm.top
                    val fontX = originX + index * xGap + (xGap - bounds.width()) / 2f
                    val fontBaseline = originY + (xAxisHeight - fontHeight) / 2f - fm.top
                    canvas.drawText(text, fontX, fontBaseline, xAxisTextPaint)
                }
                //绘制Y轴
                //轴线
                if (yAxisOptions.isEnableLine) {
                    yAxisLinePaint.strokeWidth = yAxisOptions.lineWidth
                    yAxisLinePaint.color = yAxisOptions.lineColor
                    yAxisLinePaint.pathEffect = yAxisOptions.linePathEffect
                    canvas.drawLine(originX, 0f, originX, originY, yAxisLinePaint)
                }
                yAxisTexts.forEachIndexed { index, text ->
                    //刻度线
                    val pointY = originY - index * yGap
                    if (yAxisOptions.isEnableRuler) {
                        yAxisLinePaint.strokeWidth = yAxisOptions.rulerWidth
                        yAxisLinePaint.color = yAxisOptions.rulerColor
                        canvas.drawLine(
                            originX,
                            pointY,
                            originX + yAxisOptions.rulerHeight,
                            pointY,
                            yAxisLinePaint
                        )
                    }
                    //网格线
                    if (yAxisOptions.isEnableGrid) {
                        yAxisLinePaint.strokeWidth = yAxisOptions.gridWidth
                        yAxisLinePaint.color = yAxisOptions.gridColor
                        yAxisLinePaint.pathEffect = yAxisOptions.gridPathEffect
                        canvas.drawLine(originX, pointY, width.toFloat(), pointY, yAxisLinePaint)
                    }
                    //文本
                    bounds.setEmpty()
                    yAxisTextPaint.textSize = yAxisOptions.textSize
                    yAxisTextPaint.color = yAxisOptions.textColor
                    yAxisTextPaint.getTextBounds(text, 0, text.length, bounds)
                    val fm = yAxisTextPaint.fontMetrics
                    val x = (yAxisWidth - bounds.width()) / 2f
                    val fontHeight = fm.bottom - fm.top
                    val y = originY - index * yGap - fontHeight / 2f - fm.top
                    canvas.drawText(text, x, y, yAxisTextPaint)
                }
                //绘制数据
                path.reset()
                points.forEachIndexed { index, point ->
                    val x = originX + index * xGap + xGap / 2f
                    val y = originY - (point.yAxis.toFloat() / yAxisMaxValue) * (yGap * (yAxisCount - 1))
                    if (index == 0) {
                        path.moveTo(x, y)
                    } else {
                        path.lineTo(x, y)
                    }
                    //圆点
                    circlePaint.color = dataOptions.circleColor
                    canvas.drawCircle(x, y, dataOptions.circleRadius, circlePaint)
                }
                pathPaint.strokeWidth = dataOptions.pathWidth
                pathPaint.color = dataOptions.pathColor
                canvas.drawPath(path, pathPaint)
            }
            /**
             * 设置数据
             */
            fun setData(list: List) {
                points.clear()
                points.addAll(list)
                //设置X轴、Y轴数据
                setXAxisData(list)
                setYAxisData(list)
                invalidate()
            }
            /**
             * 设置X轴数据
             */
            private fun setXAxisData(list: List) {
                val xAxisOptions = options.xAxisOptions
                val values = list.map { it.xAxis }
                //X轴文本
                xAxisTexts.clear()
                xAxisTexts.addAll(values)
                //X轴高度
                val fontMetrics = xAxisTextPaint.fontMetrics
                val lineHeight = fontMetrics.bottom - fontMetrics.top
                xAxisHeight = lineHeight + xAxisOptions.textMarginTop + xAxisOptions.textMarginBottom
            }
            /**
             * 设置Y轴数据
             */
            private fun setYAxisData(list: List) {
                val yAxisOptions = options.yAxisOptions
                yAxisTextPaint.textSize = yAxisOptions.textSize
                yAxisTextPaint.color = yAxisOptions.textColor
                val texts = list.map { it.yAxis.toString() }
                yAxisTexts.clear()
                yAxisTexts.addAll(texts)
                //Y轴高度
                val maxTextWidth = yAxisTexts.maxOf { yAxisTextPaint.measureText(it) }
                yAxisWidth = maxTextWidth + yAxisOptions.textMarginLeft + yAxisOptions.textMarginRight
                //Y轴间隔
                val maxY = list.maxOf { it.yAxis }
                val interval = when {
                    maxY <= 10 -> getYInterval(10)
                    else -> getYInterval(maxY)
                }
                //Y轴文字
                yAxisTexts.clear()
                for (index in 0..yAxisCount) {
                    val value = index * interval
                    yAxisTexts.add(formatNum(value))
                }
                yAxisMaxValue = (yAxisCount - 1) * interval
            }
            /**
             * 格式化数值
             */
            private fun formatNum(num: Int): String {
                val absNum = Math.abs(num)
                return if (absNum >= 0 && absNum < 1000) {
                    return num.toString()
                } else {
                    val format = DecimalFormat(\"0.0\")
                    val value = num / 1000f
                    \"${format.format(value)}k\"
                }
            }
            /**
             * 根据Y轴最大值、数量获取Y轴的标准间隔
             */
            private fun getYInterval(maxY: Int): Int {
                val yIntervalCount = yAxisCount - 1
                val rawInterval = maxY / yIntervalCount.toFloat()
                val magicPower = floor(log10(rawInterval.toDouble()))
                var magic = 10.0.pow(magicPower).toFloat()
                if (magic == rawInterval) {
                    magic = rawInterval
                } else {
                    magic *= 10
                }
                val rawStandardInterval = rawInterval / magic
                val standardInterval = getStandardInterval(rawStandardInterval) * magic
                return standardInterval.roundToInt()
            }
            /**
             * 根据初始的归一化后的间隔,转化为目标的间隔
             */
            private fun getStandardInterval(x: Float): Float {
                return when {
                    x <= 0.1f -> 0.1f
                    x <= 0.2f -> 0.2f
                    x <= 0.25f -> 0.25f
                    x <= 0.5f -> 0.5f
                    x <= 1f -> 1f
                    else -> getStandardInterval(x / 10) * 10
                }
            }
            /**
             * 重置参数
             */
            fun setOptions(newOptions: ChartOptions) {
                this.options = newOptions
                setData(points)
            }
            fun getOptions(): ChartOptions {
                return options
            }
            data class ChartBean(val xAxis: String, val yAxis: Int)
        }

        ChartOptions配置选项

        class ChartOptions {
            //X轴配置
            var xAxisOptions = AxisOptions()
            //Y轴配置
            var yAxisOptions = AxisOptions()
            //数据配置
            var dataOptions = DataOptions()
        }
        /**
         * 轴线配置参数
         */
        class AxisOptions {
            companion object {
                private const val DEFAULT_TEXT_SIZE = 20f
                private const val DEFAULT_TEXT_COLOR = Color.BLACK
                private const val DEFAULT_TEXT_MARGIN = 20
                private const val DEFAULT_LINE_WIDTH = 2f
                private const val DEFAULT_RULER_WIDTH = 10f
            }
            /**
             * 文字大小
             */
            @FloatRange(from = 1.0)
            var textSize: Float = DEFAULT_TEXT_SIZE
            @ColorInt
            var textColor: Int = DEFAULT_TEXT_COLOR
            /**
             * X轴文字内容上下两侧margin
             */
            var textMarginTop: Int = DEFAULT_TEXT_MARGIN
            var textMarginBottom: Int = DEFAULT_TEXT_MARGIN
            /**
             * Y轴文字内容左右两侧margin
             */
            var textMarginLeft: Int = DEFAULT_TEXT_MARGIN
            var textMarginRight: Int = DEFAULT_TEXT_MARGIN
            /**
             * 轴线
             */
            var lineWidth: Float = DEFAULT_LINE_WIDTH
            @ColorInt
            var lineColor: Int = DEFAULT_TEXT_COLOR
            var isEnableLine = true
            var linePathEffect: PathEffect? = null
            /**
             * 刻度
             */
            var rulerWidth = DEFAULT_LINE_WIDTH
            var rulerHeight = DEFAULT_RULER_WIDTH
            @ColorInt
            var rulerColor = DEFAULT_TEXT_COLOR
            var isEnableRuler = true
            /**
             * 网格
             */
            var gridWidth: Float = DEFAULT_LINE_WIDTH
            @ColorInt
            var gridColor: Int = DEFAULT_TEXT_COLOR
            var gridPathEffect: PathEffect? = null
            var isEnableGrid = true
        }
        /**
         * 数据配置参数
         */
        class DataOptions {
            companion object {
                private const val DEFAULT_PATH_WIDTH = 2f
                private const val DEFAULT_PATH_COLOR = Color.BLACK
                private const val DEFAULT_CIRCLE_RADIUS = 10f
                private const val DEFAULT_CIRCLE_COLOR = Color.BLACK
            }
            var pathWidth = DEFAULT_PATH_WIDTH
            var pathColor = DEFAULT_PATH_COLOR
            var circleRadius = DEFAULT_CIRCLE_RADIUS
            var circleColor = DEFAULT_CIRCLE_COLOR
        }

        Demo样式

        private fun initView() {
            val options = binding.chart.getOptions()
            //X轴
            val xAxisOptions = options.xAxisOptions
            xAxisOptions.isEnableLine = false
            xAxisOptions.textColor = Color.parseColor(\"#999999\")
            xAxisOptions.textSize = dpToPx(12)
            xAxisOptions.textMarginTop = dpToPx(12).toInt()
            xAxisOptions.textMarginBottom = dpToPx(12).toInt()
            xAxisOptions.isEnableGrid = false
            xAxisOptions.isEnableRuler = false
            //Y轴
            val yAxisOptions = options.yAxisOptions
            yAxisOptions.isEnableLine = false
            yAxisOptions.textColor = Color.parseColor(\"#999999\")
            yAxisOptions.textSize = dpToPx(12)
            yAxisOptions.textMarginLeft = dpToPx(12).toInt()
            yAxisOptions.textMarginRight = dpToPx(12).toInt()
            yAxisOptions.gridColor = Color.parseColor(\"#999999\")
            yAxisOptions.gridWidth = dpToPx(0.5f)
            val dashLength = dpToPx(8f)
            yAxisOptions.gridPathEffect = DashPathEffect(floatArrayOf(dashLength, dashLength / 2), 0f)
            yAxisOptions.isEnableRuler = false
            //数据
            val dataOptions = options.dataOptions
            dataOptions.pathColor = Color.parseColor(\"#79EBCF\")
            dataOptions.pathWidth = dpToPx(1f)
            dataOptions.circleColor = Color.parseColor(\"#79EBCF\")
            dataOptions.circleRadius = dpToPx(3f)
            binding.chart.setOnClickListener {
                initChartData()
            }
            binding.toolbar.setLeftClick {
                finish()
            }
        }
        
        private fun initChartData() {
            val random = 1000
            val list = mutableListOf<LineChart.ChartBean>()
            list.add(LineChart.ChartBean(\"05-01\", Random.nextInt(random)))
            list.add(LineChart.ChartBean(\"05-02\", Random.nextInt(random)))
            list.add(LineChart.ChartBean(\"05-03\", Random.nextInt(random)))
            list.add(LineChart.ChartBean(\"05-04\", Random.nextInt(random)))
            list.add(LineChart.ChartBean(\"05-05\", Random.nextInt(random)))
            list.add(LineChart.ChartBean(\"05-06\", Random.nextInt(random)))
            list.add(LineChart.ChartBean(\"05-07\", Random.nextInt(random)))
            binding.chart.setData(list)
            //文本
            val text = list.joinToString(\"\\n\") {
                \"x : ${it.xAxis}  y:${it.yAxis}\"
            }
            binding.value.text = text
        }

        总结

        到此这篇关于Android自定义折线图控件的文章就介绍到这了,更多相关Android自定义折线图控件内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

        ItVuer - 免责声明 - 关于我们 - 联系我们

        本网站信息来源于互联网,如有侵权请联系:561261067@qq.com

        桂ICP备16001015号