前言
距离上一次写东西已经过去快一年了,这一年发生了太多的事情了。上家公司拖欠工资,都离职几个月了到现在也没给,哎……。这么久没写东西,其实主要还是因为懒、心情不好。不过我现在的公司挺不错的,待遇还行,主要是离家近,开车30分钟左右就到家了,开心。好了废话就说这么多,下面言归正传。由于现在公司的项目中需要自定义的组件还是有点多的,其中曲线图和波浪进度条这两个组件花费了一些时间,之前我是谈贝塞尔色变的,不过只从写完这两个组件我感觉,也就是那么回事。所以这里记录一下,一是为了帮助别的同学,二是方便以后自己查阅。今天先从这个曲线图开始,波浪进度条会在后面的文章中为大家讲解。OK,下面开始。
贝塞尔曲线
在开始之前大家还是要对贝塞尔曲线有一定的了解的,我觉得这一篇文章讲的挺不错的,大家可以先去看下,先对贝塞尔曲线有一定的了解。也可以自行百度一下,文章有很多,也挺好理解的。
效果图
大家先来看下效果图。
分析
从上图可以看出,用贝塞尔曲线去画这个图再合适不过了。这里我们用3阶贝塞尔曲线就可以画的很完美了。
既然是曲线图,所以在开始之前我们要先分析一下点。
如上图我已经将所有的点给标出来了,每两个点之间就是一个3阶贝塞尔曲线。
知道了每两个点之前是一条贝塞尔曲线了,但是3阶贝塞尔有两个控制点,这两个控制点应该怎么定位?说到这里我就要给大家推荐一个不错的工具网站了,通过这个工具网站我们可以模拟画出我们想要的样子,然后再推算出公式,这样就很Eazy了。下面是我们来模拟一下第一个点到第二个点的曲线:
上图中黑色的线就是我们想要的曲线,蓝色的点是A就是起点(第一个点),红色的B点就是终点(第二个点),黄色的D点就是我们的控制点1,绿色的C点就是我们的控制点二。由此可以看出两个控制点的X轴坐标都是一样的(两个点的中间),那控制点X轴坐标的计算公式如下:
- 公式一
控制点X轴坐标 = (终点X轴 - 起点X轴) / 2F + 起点X轴 - 公式二
控制点X轴坐标 = (终点X轴 + 起点X轴) / 2F
以上两个公式看你高兴,用哪个都一样。
控制的X轴确定了Y轴就简单了,控制点1的Y轴和起点一样,控制点2的Y轴和终点一样。
控制点的X轴和Y轴都确定了那么就来写下伪代码吧:
val path = Path() //定义Path用来存放要画的路径
val startPoint = PointF() //假设这个是起点
val endPoint = PointF() //假设这个是终点
val centerX = (startPoint.x + endPoint.x) / 2F //根据上面的公式二计算控制点的X轴坐标。
path.moveTo(startPoint.x, startPoint.y) //先使用move方法把画笔移到起点位置。
path.cubicTo(centerX, startPoint.y, centerX, endPoint.y, endPoint.x, endPoint.y) //使用cubicTo方法绘制3阶贝塞尔曲线。
cubicTo方法的参数说明如下(按顺序):
float x1 :控制点1的X轴坐标。
float y1 :控制点1的Y轴坐标。
float x2 :控制点2的X轴坐标。
float y2 :控制点2的Y轴坐标。
float x3 :终点的X轴坐标。
float y3 :终点的Y轴坐标。
接下来我们再用上面的规则(控制点的X轴在起点和终点之间)来模拟第二个点到第三个点试一下:
测量、计算
假设我们有一堆点,不!不要假设,我们写组件就是给外部调用的。SO…… 我们先提供一个方法让别人把数据传给我们:
//用来存放所有数据。
private var dataList: List<Float> = emptyList()
//用来存放所有点。
private var points: List<PointF> = emptyList()
//用来记录最大值。
private var maxValue: Float = 100F
//设置数据源。
fun setData(data: List<Float>) {
dataList = data.apply {
points = map {
maxValue = if (maxValue >= it) maxValue else it
PointF()
}
}
}
由于数据源有几个我们就需要有多少个点,所以我在设置数据源的时候直接创建出相应个数的点(PointF)并且计算出最大值。
既然有点了我们就开始来计算这些点的位置吧。
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val w = measuredWidth //获取当前View的宽度
val h = measuredHeight //获取当前View的高度
val availableH = h - paddingTop - paddingBottom - lineWidth //计算真正可用的高度,lineWidth 是曲线画笔的strokeWidth。把它减掉是为了防止曲线画不完整。
//计算单个间距的宽度
val oneSpace = (w.toFloat() - paddingStart - paddingEnd) / dataList.lastIndex
//计算左边的边距
val leftStart = paddingStart + lineWidth * 0.5F
//计算曲线图顶部的距离
val graphTop = paddingTop + lineWidth * 0.5F
points.forEachIndexed { i, p ->
p.x = leftStart + i * oneSpace //计算每个点的X轴坐标
p.y = graphTop + (availableH - dataList[i] / maxValue * availableH) //计算每个点Y轴的坐标,顶部的距离+当前数据占最大值的百分比然后换算出占View高度的百分比。
}
得到所有的点之后就利用我们上面分析的来绘制曲线就OK了:
//计算曲线图路径
linePath.reset()
var startP: PointF
var endP: PointF
for (i in 0 until points.lastIndex) {
startP = points[i]
endP = points[i + 1]
if (i == 0) {
linePath.moveTo(startP.x, startP.y)
}
((startP.x + endP.x) / 2F).also {
linePath.cubicTo(it, startP.y, it, endP.y, endP.x, endP.y)
}
}
最后就是用canvas画出来。
canvas.drawPath(linePath, graphPaint)
到这里就已经可以把我们要的曲线画出来了。至于渐变背景填充色,坐标线啥的就So Easy了。以下是全部的代码:
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.View
/**
* **描述:** 贝塞尔曲线图组件
*
* **创建人:** kelin
*
* **创建时间:** 2021/9/3 6:26 PM
*
* **版本:** v 1.0.0
*/
class GraphView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : View(context, attrs, defStyleAttr) {
/**
* 用来存放曲线路径。
*/
private val linePath = Path()
/**
* 用来存放曲线图背景的路径。
*/
private val graphBgPath = Path()
/**
* 用来存放所有数据。
*/
private var dataList: List<Float> = emptyList()
/**
* 用来记录最大值。
*/
private var maxValue: Float = 0F
/**
* 用来存放所有点。
*/
private var points: List<PointF> = emptyList()
/**
* 用来定义选中轴的标线所超出图表的大小。
*/
private val axisLineOverlySize = 4.dp2pxF
/**
* 定义曲线的宽度。
*/
private val lineWidth = 4.dp2pxF
/**
* 用来记录最大的点的位置。
*/
private var maxPoint: PointF = PointF()
/**
* 定义用来画曲线的画笔。
*/
private val graphPaint by lazy {
Paint(Paint.ANTI_ALIAS_FLAG).apply {
strokeWidth = lineWidth
strokeCap = Paint.Cap.ROUND
}
}
/**
* 定义曲线图背景渐变着色器。
*/
private val graphBgGradient by lazy {
LinearGradient(
0F,
0F,
0F,
measuredHeight.toFloat(),
intArrayOf(Color.parseColor("#7DBAEFE6"), Color.parseColor("#7DD7F5F0"), Color.parseColor("#7DF9FEFD"), Color.WHITE),
listOf(0.5F, 0.65F, 0.85F, 1F).toFloatArray(),
Shader.TileMode.REPEAT
)
}
/**
* 设置数据源。
*/
fun setData(data: List<Float>) {
dataList = data.apply {
points = map {
maxValue = if (maxValue >= it) maxValue else it
PointF()
}
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val w = measuredWidth
val h = measuredHeight
val availableH = h - paddingTop - paddingBottom - lineWidth * 4F - axisLineOverlySize
//计算单个间距的宽度
val oneSpace = (w.toFloat() - lineWidth * 3F - paddingStart - paddingEnd) / dataList.lastIndex //计算左边的边距
val leftStart = paddingStart + lineWidth * 1.5F
val graphTop = paddingTop + lineWidth * 1.5F + axisLineOverlySize
points.forEachIndexed { i, p ->
p.x = leftStart + i * oneSpace
dataList[i].also {
p.y = graphTop + (availableH - it / maxValue * availableH)
if (maxPoint.y == 0F && maxValue == it) {
maxPoint = p
}
}
}
//计算曲线图路径
linePath.reset()
var startP: PointF
var endP: PointF
for (i in 0 until points.lastIndex) {
startP = points[i]
endP = points[i + 1]
if (i == 0) {
linePath.moveTo(startP.x, startP.y)
}
((startP.x + endP.x) / 2F).also {
linePath.cubicTo(it, startP.y, it, endP.y, endP.x, endP.y)
}
}
//计算曲线图背景路径。
graphBgPath.set(linePath)
val bottom = h - paddingBottom - axisLineOverlySize
graphBgPath.lineTo(points.last().x, bottom)
graphBgPath.lineTo(leftStart, bottom)
}
override fun onDraw(canvas: Canvas) {
//画渐变底色
graphPaint.apply {
style = Paint.Style.FILL
shader = graphBgGradient
}
canvas.drawPath(graphBgPath, graphPaint)
//画曲线线条
graphPaint.apply {
color = Color.parseColor("#0AA490")
style = Paint.Style.STROKE
shader = null
}
canvas.drawPath(linePath, graphPaint)
//画当前轴的轴线
graphPaint.strokeWidth = 0.8F.dp2pxF
canvas.drawLine(maxPoint.x, 0F + paddingTop, maxPoint.x, height.toFloat() - paddingBottom, graphPaint)
//画当前坐标点
graphPaint.style = Paint.Style.FILL
canvas.drawCircle(maxPoint.x, maxPoint.y, lineWidth, graphPaint)
graphPaint.strokeWidth = lineWidth * 0.7F
graphPaint.color = Color.BLACK
graphPaint.style = Paint.Style.STROKE
canvas.drawCircle(maxPoint.x, maxPoint.y, lineWidth, graphPaint)
}
}
val Int.dp2pxF: Float
get() = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, toFloat(), AppModule.getContext().resources.displayMetrics)
说明
我们需求是不能左右滑动的,而且滑动也不再本篇文章的讨论范畴,所以并不支持左右滑动。另外我们的需求是不能手动点击改变当前坐标的,只能选中最大坐标。
实现效果
最后贴出我实现的效果
最后
如果你喜欢本文内容,或本文内容对你有所帮助,还请点赞、收藏哦。您的支持是我继续创作的动力。