热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

Android 实现自定义折线图控件_Android

这篇文章主要介绍了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.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轴的刻度间隔:

这里处理的方式比较随意,直接将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()
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()
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
}


推荐阅读
author-avatar
四月草上飞2602920415
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有