失眠网,内容丰富有趣,生活中的好帮手!
失眠网 > Android丨自定义折线图

Android丨自定义折线图

时间:2020-03-09 00:28:42

相关推荐

Android丨自定义折线图

前言

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

概述

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

原点X轴Y轴折线

原点

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

可以发现,原点的位置由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.fontMetricsval lineHeight = fontMetrics.bottom - fontMetrics.topxAxisHeight = lineHeight + xAxisOptions.textMarginTop + xAxisOptions.textMarginBottom

X轴

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

绘制轴线

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

if (xAxisOptions.isEnableLine) {xAxisLinePaint.strokeWidth = xAxisOptions.lineWidthxAxisLinePaint.color = xAxisOptions.lineColorxAxisLinePaint.pathEffect = xAxisOptions.linePathEffectcanvas.drawLine(originX, originY, width.toFloat(), originY, xAxisLinePaint)}

X轴刻度间隔

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

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

xGap = (width - originX) / 7

网格线、文本

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

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

xAxisTexts.forEachIndexed { index, text ->val pointX = originX + index * xGap//刻度线if (xAxisOptions.isEnableRuler) {xAxisLinePaint.strokeWidth = xAxisOptions.rulerWidthxAxisLinePaint.color = xAxisOptions.rulerColorcanvas.drawLine(pointX, originY,pointX, originY - xAxisOptions.rulerHeight,xAxisLinePaint)}//网格线if (xAxisOptions.isEnableGrid) {xAxisLinePaint.strokeWidth = xAxisOptions.gridWidthxAxisLinePaint.color = xAxisOptions.gridColorxAxisLinePaint.pathEffect = xAxisOptions.gridPathEffectcanvas.drawLine(pointX, originY, pointX, 0f, xAxisLinePaint)}//文本bounds.setEmpty()xAxisTextPaint.textSize = xAxisOptions.textSizexAxisTextPaint.color = xAxisOptions.textColorxAxisTextPaint.getTextBounds(text, 0, text.length, bounds)val fm = xAxisTextPaint.fontMetricsval fontHeight = fm.bottom - fm.topval fontX = originX + index * xGap + (xGap - bounds.width()) / 2fval fontBaseline = originY + (xAxisHeight - fontHeight) / 2f - fm.topcanvas.drawText(text, fontX, fontBaseline, xAxisTextPaint)}

Y轴

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

计算Y轴分布

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

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

/*** 根据Y轴最大值、数量获取Y轴的标准间隔*/private fun getYInterval(maxY: Int): Int {val yIntervalCount = yAxisCount - 1val 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 / magicval standardInterval = getStandardInterval(rawStandardInterval) * magicreturn standardInterval.roundToInt()}/*** 根据初始的归一化后的间隔,转化为目标的间隔*/private fun getStandardInterval(x: Float): Float {return when {x <= 0.1f -> 0.1fx <= 0.2f -> 0.2fx <= 0.25f -> 0.25fx <= 0.5f -> 0.5fx <= 1f -> 1felse -> getStandardInterval(x / 10) * 10}}

刻度间隔、网格线、文本

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

//绘制Y轴//轴线if (yAxisOptions.isEnableLine) {yAxisLinePaint.strokeWidth = yAxisOptions.lineWidthyAxisLinePaint.color = yAxisOptions.lineColoryAxisLinePaint.pathEffect = yAxisOptions.linePathEffectcanvas.drawLine(originX, 0f, originX, originY, yAxisLinePaint)}yAxisTexts.forEachIndexed { index, text ->//刻度线val pointY = originY - index * yGapif (yAxisOptions.isEnableRuler) {yAxisLinePaint.strokeWidth = yAxisOptions.rulerWidthyAxisLinePaint.color = yAxisOptions.rulerColorcanvas.drawLine(originX,pointY,originX + yAxisOptions.rulerHeight,pointY,yAxisLinePaint)}//网格线if (yAxisOptions.isEnableGrid) {yAxisLinePaint.strokeWidth = yAxisOptions.gridWidthyAxisLinePaint.color = yAxisOptions.gridColoryAxisLinePaint.pathEffect = yAxisOptions.gridPathEffectcanvas.drawLine(originX, pointY, width.toFloat(), pointY, yAxisLinePaint)}//文本bounds.setEmpty()yAxisTextPaint.textSize = yAxisOptions.textSizeyAxisTextPaint.color = yAxisOptions.textColoryAxisTextPaint.getTextBounds(text, 0, text.length, bounds)val fm = yAxisTextPaint.fontMetricsval x = (yAxisWidth - bounds.width()) / 2fval fontHeight = fm.bottom - fm.topval y = originY - index * yGap - fontHeight / 2f - fm.topcanvas.drawText(text, x, y, yAxisTextPaint)}

折线

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

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

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

代码

折线图LineChart

package com.vander.pool.widget.linechartimport android.content.Contextimport android.graphics.*import android.text.TextPaintimport android.util.AttributeSetimport android.view.Viewimport java.text.DecimalFormatimport kotlin.math.floorimport kotlin.math.log10import kotlin.math.powimport kotlin.math.roundToIntclass 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 = 0fprivate val yAxisCount = 5private var yAxisMaxValue: Int = 0/*** 原点*/private var originX = 0fprivate var originY = 0fprivate var xGap = 0fprivate 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()) returnval xAxisOptions = options.xAxisOptionsval yAxisOptions = options.yAxisOptionsval dataOptions = options.dataOptions//设置原点originX = yAxisWidthoriginY = height - xAxisHeight//设置X轴Y轴间隔xGap = (width - originX) / points.size//Y轴默认顶部会留出一半空间yGap = originY / (yAxisCount - 1 + 0.5f)//绘制X轴//轴线if (xAxisOptions.isEnableLine) {xAxisLinePaint.strokeWidth = xAxisOptions.lineWidthxAxisLinePaint.color = xAxisOptions.lineColorxAxisLinePaint.pathEffect = xAxisOptions.linePathEffectcanvas.drawLine(originX, originY, width.toFloat(), originY, xAxisLinePaint)}xAxisTexts.forEachIndexed { index, text ->val pointX = originX + index * xGap//刻度线if (xAxisOptions.isEnableRuler) {xAxisLinePaint.strokeWidth = xAxisOptions.rulerWidthxAxisLinePaint.color = xAxisOptions.rulerColorcanvas.drawLine(pointX, originY,pointX, originY - xAxisOptions.rulerHeight,xAxisLinePaint)}//网格线if (xAxisOptions.isEnableGrid) {xAxisLinePaint.strokeWidth = xAxisOptions.gridWidthxAxisLinePaint.color = xAxisOptions.gridColorxAxisLinePaint.pathEffect = xAxisOptions.gridPathEffectcanvas.drawLine(pointX, originY, pointX, 0f, xAxisLinePaint)}//文本bounds.setEmpty()xAxisTextPaint.textSize = xAxisOptions.textSizexAxisTextPaint.color = xAxisOptions.textColorxAxisTextPaint.getTextBounds(text, 0, text.length, bounds)val fm = xAxisTextPaint.fontMetricsval fontHeight = fm.bottom - fm.topval fontX = originX + index * xGap + (xGap - bounds.width()) / 2fval fontBaseline = originY + (xAxisHeight - fontHeight) / 2f - fm.topcanvas.drawText(text, fontX, fontBaseline, xAxisTextPaint)}//绘制Y轴//轴线if (yAxisOptions.isEnableLine) {yAxisLinePaint.strokeWidth = yAxisOptions.lineWidthyAxisLinePaint.color = yAxisOptions.lineColoryAxisLinePaint.pathEffect = yAxisOptions.linePathEffectcanvas.drawLine(originX, 0f, originX, originY, yAxisLinePaint)}yAxisTexts.forEachIndexed { index, text ->//刻度线val pointY = originY - index * yGapif (yAxisOptions.isEnableRuler) {yAxisLinePaint.strokeWidth = yAxisOptions.rulerWidthyAxisLinePaint.color = yAxisOptions.rulerColorcanvas.drawLine(originX,pointY,originX + yAxisOptions.rulerHeight,pointY,yAxisLinePaint)}//网格线if (yAxisOptions.isEnableGrid) {yAxisLinePaint.strokeWidth = yAxisOptions.gridWidthyAxisLinePaint.color = yAxisOptions.gridColoryAxisLinePaint.pathEffect = yAxisOptions.gridPathEffectcanvas.drawLine(originX, pointY, width.toFloat(), pointY, yAxisLinePaint)}//文本bounds.setEmpty()yAxisTextPaint.textSize = yAxisOptions.textSizeyAxisTextPaint.color = yAxisOptions.textColoryAxisTextPaint.getTextBounds(text, 0, text.length, bounds)val fm = yAxisTextPaint.fontMetricsval x = (yAxisWidth - bounds.width()) / 2fval fontHeight = fm.bottom - fm.topval y = originY - index * yGap - fontHeight / 2f - fm.topcanvas.drawText(text, x, y, yAxisTextPaint)}//绘制数据path.reset()points.forEachIndexed { index, point ->val x = originX + index * xGap + xGap / 2fval y = originY - (point.yAxis.toFloat() / yAxisMaxValue) * (yGap * (yAxisCount - 1))if (index == 0) {path.moveTo(x, y)} else {path.lineTo(x, y)}//圆点circlePaint.color = dataOptions.circleColorcanvas.drawCircle(x, y, dataOptions.circleRadius, circlePaint)}pathPaint.strokeWidth = dataOptions.pathWidthpathPaint.color = dataOptions.pathColorcanvas.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.xAxisOptionsval values = list.map { it.xAxis }//X轴文本xAxisTexts.clear()xAxisTexts.addAll(values)//X轴高度val fontMetrics = xAxisTextPaint.fontMetricsval lineHeight = fontMetrics.bottom - fontMetrics.topxAxisHeight = lineHeight + xAxisOptions.textMarginTop + xAxisOptions.textMarginBottom}/*** 设置Y轴数据*/private fun setYAxisData(list: List<ChartBean>) {val yAxisOptions = options.yAxisOptionsyAxisTextPaint.textSize = yAxisOptions.textSizeyAxisTextPaint.color = yAxisOptions.textColorval 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 * intervalyAxisTexts.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 - 1val 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 / magicval standardInterval = getStandardInterval(rawStandardInterval) * magicreturn standardInterval.roundToInt()}/*** 根据初始的归一化后的间隔,转化为目标的间隔*/private fun getStandardInterval(x: Float): Float {return when {x <= 0.1f -> 0.1fx <= 0.2f -> 0.2fx <= 0.25f -> 0.25fx <= 0.5f -> 0.5fx <= 1f -> 1felse -> getStandardInterval(x / 10) * 10}}/*** 重置参数*/fun setOptions(newOptions: ChartOptions) {this.options = newOptionssetData(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 = 20fprivate const val DEFAULT_TEXT_COLOR = Color.BLACKprivate const val DEFAULT_TEXT_MARGIN = 20private const val DEFAULT_LINE_WIDTH = 2fprivate const val DEFAULT_RULER_WIDTH = 10f}/*** 文字大小*/@FloatRange(from = 1.0)var textSize: Float = DEFAULT_TEXT_SIZE@ColorIntvar textColor: Int = DEFAULT_TEXT_COLOR/*** X轴文字内容上下两侧margin*/var textMarginTop: Int = DEFAULT_TEXT_MARGINvar textMarginBottom: Int = DEFAULT_TEXT_MARGIN/*** Y轴文字内容左右两侧margin*/var textMarginLeft: Int = DEFAULT_TEXT_MARGINvar textMarginRight: Int = DEFAULT_TEXT_MARGIN/*** 轴线*/var lineWidth: Float = DEFAULT_LINE_WIDTH@ColorIntvar lineColor: Int = DEFAULT_TEXT_COLORvar isEnableLine = truevar linePathEffect: PathEffect? = null/*** 刻度*/var rulerWidth = DEFAULT_LINE_WIDTHvar rulerHeight = DEFAULT_RULER_WIDTH@ColorIntvar rulerColor = DEFAULT_TEXT_COLORvar isEnableRuler = true/*** 网格*/var gridWidth: Float = DEFAULT_LINE_WIDTH@ColorIntvar gridColor: Int = DEFAULT_TEXT_COLORvar gridPathEffect: PathEffect? = nullvar isEnableGrid = true}/*** 数据配置参数*/class DataOptions {companion object {private const val DEFAULT_PATH_WIDTH = 2fprivate const val DEFAULT_PATH_COLOR = Color.BLACKprivate const val DEFAULT_CIRCLE_RADIUS = 10fprivate const val DEFAULT_CIRCLE_COLOR = Color.BLACK}var pathWidth = DEFAULT_PATH_WIDTHvar pathColor = DEFAULT_PATH_COLORvar circleRadius = DEFAULT_CIRCLE_RADIUSvar circleColor = DEFAULT_CIRCLE_COLOR}

Demo样式

private fun initView() {val options = binding.chart.getOptions()//X轴val xAxisOptions = options.xAxisOptionsxAxisOptions.isEnableLine = falsexAxisOptions.textColor = Color.parseColor("#999999")xAxisOptions.textSize = dpToPx(12)xAxisOptions.textMarginTop = dpToPx(12).toInt()xAxisOptions.textMarginBottom = dpToPx(12).toInt()xAxisOptions.isEnableGrid = falsexAxisOptions.isEnableRuler = false//Y轴val yAxisOptions = options.yAxisOptionsyAxisOptions.isEnableLine = falseyAxisOptions.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.dataOptionsdataOptions.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 = 1000val 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丨自定义折线图》对你有帮助,请点赞、收藏,并留下你的观点哦!

本内容不代表本网观点和政治立场,如有侵犯你的权益请联系我们处理。
网友评论
网友评论仅供其表达个人看法,并不表明网站立场。