Android貝塞爾曲線之曲線圖

前言

距離上一次寫東西已經過去快一年了,這一年發生了太多的事情了。上家公司拖欠工資,都離職幾個月了到現在也沒給,哎……。這麼久沒寫東西,其實主要還是因爲懶、心情不好。不過我現在的公司挺不錯的,待遇還行,主要是離家近,開車30分鐘左右就到家了,開心。好了廢話就說這麼多,下面言歸正傳。由於現在公司的項目中需要自定義的組件還是有點多的,其中曲線圖和波浪進度條這兩個組件花費了一些時間,之前我是談貝塞爾色變的,不過只從寫完這兩個組件我感覺,也就是那麼回事。所以這裏記錄一下,一是爲了幫助別的同學,二是方便以後自己查閱。今天先從這個曲線圖開始,波浪進度條會在後面的文章中爲大家講解。OK,下面開始。

貝塞爾曲線

在開始之前大家還是要對貝塞爾曲線有一定的瞭解的,我覺得這一篇文章講的挺不錯的,大家可以先去看下,先對貝塞爾曲線有一定的瞭解。也可以自行百度一下,文章有很多,也挺好理解的。

效果圖

大家先來看下效果圖。


分析

從上圖可以看出,用貝塞爾曲線去畫這個圖再合適不過了。這裏我們用3階貝塞爾曲線就可以畫的很完美了。

既然是曲線圖,所以在開始之前我們要先分析一下點。


如上圖我已經將所有的點給標出來了,每兩個點之間就是一個3階貝塞爾曲線。

知道了每兩個點之前是一條貝塞爾曲線了,但是3階貝塞爾有兩個控制點,這兩個控制點應該怎麼定位?說到這裏我就要給大家推薦一個不錯的工具網站了,通過這個工具網站我們可以模擬畫出我們想要的樣子,然後再推算出公式,這樣就很Eazy了。下面是我們來模擬一下第一個點到第二個點的曲線:

上圖中黑色的線就是我們想要的曲線,藍色的點是A就是起點(第一個點),紅色的B點就是終點(第二個點),黃色的D點就是我們的控制點1,綠色的C點就是我們的控制點二。由此可以看出兩個控制點的X軸座標都是一樣的(兩個點的中間),那控制點X軸座標的計算公式如下:

  1. 公式一
    控制點X軸座標 = (終點X軸 - 起點X軸) / 2F + 起點X軸
  2. 公式二
    控制點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)

說明

我們需求是不能左右滑動的,而且滑動也不再本篇文章的討論範疇,所以並不支持左右滑動。另外我們的需求是不能手動點擊改變當前座標的,只能選中最大座標。

實現效果

最後貼出我實現的效果


最後

如果你喜歡本文內容,或本文內容對你有所幫助,還請點贊、收藏哦。您的支持是我繼續創作的動力。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章