前言
距離上一次寫東西已經過去快一年了,這一年發生了太多的事情了。上家公司拖欠工資,都離職幾個月了到現在也沒給,哎……。這麼久沒寫東西,其實主要還是因爲懶、心情不好。不過我現在的公司挺不錯的,待遇還行,主要是離家近,開車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)
說明
我們需求是不能左右滑動的,而且滑動也不再本篇文章的討論範疇,所以並不支持左右滑動。另外我們的需求是不能手動點擊改變當前座標的,只能選中最大座標。
實現效果
最後貼出我實現的效果
最後
如果你喜歡本文內容,或本文內容對你有所幫助,還請點贊、收藏哦。您的支持是我繼續創作的動力。