温馨提示×

温馨提示×

您好,登录后才能下订单哦!

密码登录×
登录注册×
其他方式登录
点击 登录注册 即表示同意《亿速云用户服务条款》

Android怎么实现自定义折线图控件

发布时间:2022-06-16 10:03:14 来源:亿速云 阅读:245 作者:iii 栏目:开发技术

这篇“Android怎么实现自定义折线图控件”文章的知识点大部分人都不太理解,所以小编给大家总结了以下内容,内容详细,步骤清晰,具有一定的借鉴价值,希望大家阅读完这篇文章能有所收获,下面我们一起来看看这篇“Android怎么实现自定义折线图控件”文章吧。

    前言

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

    Android怎么实现自定义折线图控件

    概述

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

    • 原点

    • X轴

    • Y轴

    • 折线

    原点

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

    Android怎么实现自定义折线图控件

    可以发现,原点的位置由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怎么实现自定义折线图控件

    这里处理的方式比较随意,直接将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轴分布

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

    基于JavaScript实现数值型坐标轴刻度计算算法(echarts的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<String>()
        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<String>()
        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<ChartBean>()
        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<ChartBean>) {
            points.clear()
            points.addAll(list)
            //设置X轴、Y轴数据
            setXAxisData(list)
            setYAxisData(list)
            invalidate()
        }
        /**
         * 设置X轴数据
         */
        private fun setXAxisData(list: List<ChartBean>) {
            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<ChartBean>) {
            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怎么实现自定义折线图控件”这篇文章的内容,相信大家都有了一定的了解,希望小编分享的内容对大家有帮助,若想了解更多相关的知识内容,请关注亿速云行业资讯频道。

    向AI问一下细节

    免责声明:本站发布的内容(图片、视频和文字)以原创、转载和分享为主,文章观点不代表本网站立场,如果涉及侵权请联系站长邮箱:is@yisu.com进行举报,并提供相关证据,一经查实,将立刻删除涉嫌侵权内容。

    AI