1. 程式人生 > >一步步實現自定義View之雷達圖

一步步實現自定義View之雷達圖

之前在專案中需要用到雷達圖,我就在github上挑了一個用於專案中實現了需求。但是作為一隻有追求的程式猿,我還是想自己實現一下,忙裡偷閒地實現了一個雷達圖。下面看一下效果圖吧:

這裡寫圖片描述

接著詳細地介紹一下我的實現思路吧

1.繪製背景圖

首先這裡需要注意的一點是,我需要將這個背景繪製在整個View的中間(從Gif圖中可以看出),我需要先將整個畫布平移
translateX = ( 整個View的寬度 - 雷達圖的寬度) / 2
translateY = (整個View的高度 - 雷達圖的高度) / 2
這樣就能把整個雷達圖背景繪製在中間了

//先畫整個背景
val radarWidth = Math.sqrt
(3.0) * mRadarBorderWidth val horizontalOffset = (mWidth - radarWidth).div(2).toFloat() val verticalOffset = (mHeight - mRadarBorderWidth * 2).div(2) canvas!!.translate(horizontalOffset,verticalOffset)

背景圖分為兩個部分,一個部分是五個六邊形,另一部分是三條直線

這裡寫圖片描述
我在這裡設六邊形的邊長為X,再根據圓點的座標,我可以計算出最外層六邊形的六個點的座標:

橫座標 縱座標
A點 32 / 2 * X 0
B點 0 1/2 * X
C點 0 3/2 * X
D點 32 / 2 * X 2 * X
E點 32 * X 32 / 2 * X
F點 32 * X 1/2 * X

得到這六個點的座標以後,就可以通過Path連起來了。
我現在這裡畫一個雷達圖的整體背景:
在onDraw方法中

drawRadarBackground(canvas)
/*
* 繪製雷達圖的背景
* */
private fun drawRadarBackground(canvas: Canvas){
   //根號三
val sqrt = Math.sqrt(3.0) //二分之根號三的mRadarBorderWidth val base = (sqrt * mRadarBorderWidth.div(2)).toFloat() val bgPath = Path() bgPath.moveTo( base,0f ) // A bgPath.lineTo(0f , mRadarBorderWidth.div(2) )// B bgPath.lineTo(0f , mRadarBorderWidth.div(2) * 3 )// C bgPath.lineTo(base, mRadarBorderWidth * 2 )// D bgPath.lineTo(base * 2 , mRadarBorderWidth.div(2) * 3 )// E bgPath.lineTo(base * 2 , mRadarBorderWidth.div(2) )// F bgPath.close() canvas.drawPath(bgPath,mBgPaint!!) }

背景裡面需要畫五個六邊形,大的六邊形座標上面已經有了,這裡舉其中的一個小的來看,其他幾個都是一個道理的
這裡寫圖片描述
觀察上圖我們可以發現,AD座標變化是同一型別的,BCEF變化是同一型別
首先我們看一下,A是如何到A1的
前面我們已經知道A點座標(32 / 2 * X,0)
A1的橫座標和A相同,縱座標比A大了1/5 * X,所以A1的座標就是(32 /2 * X,1/5 * X)

再來看B如何變化到B1:
B1橫座標 = B橫座標 + 1/5 * X * 32 / 2
B1縱座標 = B縱座標 + 1/5 * X * 1/2

同理我們可以得到其他所有的座標,其他的小六邊形都是一個原理。這五個六邊形的點可以用一個for迴圈來得到
在onDraw方法中

//畫雷達圖線條
for (index in 0 until 5){
    drawRadarBorder(canvas,index * 0.2f)
}
/*
* 繪製雷達圖的六邊形邊框
* */
private fun drawRadarBorder(canvas: Canvas,percent: Float) {
    //根號三
    val sqrt = Math.sqrt(3.0)
    //二分之根號三的mRadarBorderWidth
    val base = (sqrt * mRadarBorderWidth.div(2)).toFloat()
    val out = Path()
    out.moveTo( base,0f + mRadarBorderWidth * percent) // A
    out.lineTo(0f + base * percent, mRadarBorderWidth.div(2) + mRadarBorderWidth.div(2) * percent)// B
    out.lineTo(0f + base * percent, mRadarBorderWidth.div(2) * 3 - mRadarBorderWidth.div(2) * percent)// C
    out.lineTo(base, mRadarBorderWidth * 2 - mRadarBorderWidth * percent)// D
    out.lineTo(base * 2 - base * percent, mRadarBorderWidth.div(2) * 3 - mRadarBorderWidth.div(2) * percent)// E
    out.lineTo(base * 2 - base * percent, mRadarBorderWidth.div(2) + mRadarBorderWidth.div(2) * percent)// F
    out.close()

    canvas.drawPath(out,mBorderPaint)
}

三條直線很簡單,將AD連線、BE連線、CF連線起來就是這三條直線了
在onDraw方法中

drawLine(canvas)
/*
* 畫雷達圖內部的直線
* */
private fun drawLine(canvas: Canvas) {
    //二分之根號三的mRadarBorderWidth
    val base = (Math.sqrt(3.0) * mRadarBorderWidth.div(2)).toFloat()

    canvas.drawLine( base, 0f, base, mRadarBorderWidth * 2,mBorderPaint)//A - D
    canvas.drawLine( 0f, mRadarBorderWidth.div(2), base * 2,mRadarBorderWidth.div(2) * 3,mBorderPaint)//B - E
    canvas.drawLine( 0f, mRadarBorderWidth.div(2) * 3, base * 2 ,mRadarBorderWidth.div(2),mBorderPaint)//C - F

}

2.繪製外面的文字

AD點的文字是需要頂點的上方以及中間,BC點的文字是在其頂點的左上,EF點的文字在其頂點的右上
上面繪製雷達圖背景的時候將畫布平移了,現在把畫布回覆到上一個狀態,以整個View的左上為起點。
接著重新將畫布平移
translateX = ( 整個View的寬度 - 雷達圖的寬度) / 2 - (B或者C中寬度較大的值)
translateY = (整個View的高度 - 雷達圖的高度) / 2 - A點文字的高度
這樣做的目的是讓文字的繪製可以從相對於(0,0)的位置開始

canvas.restore()
canvas.save()
canvas.translate(horizontalOffset - mTextOffsetWidth,verticalOffset - mTextOffsetHeight)

如圖所示
這裡寫圖片描述
這樣做的話,文字的座標比較好確定。

drawText(canvas)
/*
* 繪製雷達圖外側的文字
* */
private fun drawText(canvas: Canvas) {
    //二分之根號三的mRadarBorderWidth
    val base = (Math.sqrt(3.0) * mRadarBorderWidth.div(2)).toFloat()
    val textWidthA = mTextPaint!!.measureText(mStrA)
    val textHeight = getFontHeight(mTextPaint!!)
    //mTextOffsetHeight 由A點決定
    mTextOffsetHeight = textHeight + mTextMargin


    //mTextOffsetWidth 由 B C 中較寬的決定
    val widthB = mTextPaint!!.measureText(mStrB)
    val widthC = mTextPaint!!.measureText(mStrC)
    var offset = Math.abs((widthB - widthC))
    if(widthB > widthC){
        mTextOffsetWidth = widthB + mTextMargin
        canvas.drawText(mStrB,0f,mTextOffsetHeight + mRadarBorderWidth.div(2),mTextPaint)//B
        canvas.drawText(mStrC,offset + 0f,mTextOffsetHeight + mRadarBorderWidth.div(2) * 3,mTextPaint)//C
    }else{
        mTextOffsetWidth = widthC + mTextMargin
        canvas.drawText(mStrB,offset + 0f,mTextOffsetHeight + mRadarBorderWidth.div(2),mTextPaint)//B
        canvas.drawText(mStrC,0f,mTextOffsetHeight + mRadarBorderWidth.div(2) * 3,mTextPaint)//C
    }
    canvas.drawText(mStrA,mTextOffsetWidth + base - textWidthA.div(2),textHeight,mTextPaint)//A


    //D點的文字
    val textWidthD = mTextPaint!!.measureText(mStrD)
    canvas.drawText(mStrD,mTextOffsetWidth + base - textWidthD.div(2),textHeight * 2 + mRadarBorderWidth * 2 + mTextMargin * 2,mTextPaint)

    //E點座標的文字
    canvas.drawText(mStrE,base * 2 + mTextOffsetWidth + mTextMargin,mTextOffsetHeight + mRadarBorderWidth.div(2) * 3,mTextPaint)

    //F點座標的文字
    canvas.drawText(mStrF,base * 2 + mTextOffsetWidth + mTextMargin,mTextOffsetHeight + mRadarBorderWidth.div(2),mTextPaint)

}

3.繪製動畫和內部的文字

在剛才的座標系的基礎上,繪製內部顯示的動畫
我們可以先拿到雷達圖的中心點

val cons = Math.sqrt(3.0).div(2)
val base = (Math.sqrt(3.0) * mRadarBorderWidth.div(2)).toFloat()
//這裡這麼做的原因是上面已經將畫布的座標系移回去了,否則不需要再加textOffset
val centerX = mTextOffsetWidth + base
val centerY = mTextOffsetHeight + mRadarBorderWidth

在A點與中心點這條線段上移動的點,我們可以通過設定的動畫拿到一個變化的量mCurrentA,那麼實時的高度就是
mCurrentA / 100 * 六邊形的半徑X,我們可以得知這個動態點的座標是(centerX,(centerY - mCurrentA.div(100) * mRadarBorderWidth).toFloat())
其實這些動態點的獲得原理和第一步中獲得內部小六邊形的點的方式差不多,區別只不過是一個是動態的,一個是靜態的。這裡就不再多解釋了。

val path = Path()
//繪製A點的動態座標
path.moveTo(centerX,(centerY - mCurrentA.div(100) * mRadarBorderWidth).toFloat())
//繪製B點
path.lineTo((centerX - mCurrentB.div(100) * mRadarBorderWidth * cons).toFloat(),
        (centerY - mCurrentB.div(100)*mRadarBorderWidth.div(2)).toFloat())

//繪製C點
path.lineTo((centerX - mCurrentC.div(100) * mRadarBorderWidth * cons).toFloat(),
        (centerY + mCurrentC.div(100) * mRadarBorderWidth.div(2)).toFloat())

//繪製D點
path.lineTo(centerX,(centerY + mCurrentD.div(100) * mRadarBorderWidth).toFloat())

//繪製E點
path.lineTo((centerX + mCurrentE.div(100) * mRadarBorderWidth * cons).toFloat(),
        (centerY + mCurrentE.div(100)*mRadarBorderWidth.div(2)).toFloat())

//繪製F點
path.lineTo((centerX + mCurrentF.div(100) * mRadarBorderWidth * cons).toFloat(),
        (centerY - mCurrentF.div(100)*mRadarBorderWidth.div(2)).toFloat())

path.close()

canvas.drawPath(path,mProgressPaint!!)

文字的顯示原理和第二步中繪製文字差不多,區別也是這裡多了一個動態的引數而已

val measureWidthA = mInnerTextPaint!!.measureText(mProgressA.toString())
canvas.drawText(mProgressA.toString(),centerX - measureWidthA.div(2),(centerY - mProgressA.div(100) * mRadarBorderWidth).toFloat(),mInnerTextPaint!!)

val measureWidthB = mInnerTextPaint!!.measureText(mProgressB.toString())
canvas.drawText(mProgressB.toString(),(centerX - mProgressB.div(100) * mRadarBorderWidth * cons - measureWidthB).toFloat(),
                 (centerY - mProgressB.div(100)*mRadarBorderWidth.div(2)).toFloat(),mInnerTextPaint!!)

val measureWidthC = mInnerTextPaint!!.measureText(mProgressC.toString())
canvas.drawText(mProgressC.toString(),(centerX - mProgressC.div(100) * mRadarBorderWidth * cons - measureWidthC).toFloat(),
                 (centerY + mProgressC.div(100)*mRadarBorderWidth.div(2)).toFloat(),mInnerTextPaint!!)

val measureWidthD = mInnerTextPaint!!.measureText(mProgressD.toString())
val measureHeightD = getFontHeight(mInnerTextPaint!!)
canvas.drawText(mProgressD.toString(),centerX - measureWidthD.div(2),(centerY + mProgressD.div(100) * mRadarBorderWidth + measureHeightD).toFloat(),mInnerTextPaint!!)

canvas.drawText(mProgressE.toString(),(centerX + mProgressE.div(100) * mRadarBorderWidth * cons).toFloat(),
                 (centerY + mProgressE.div(100)*mRadarBorderWidth.div(2)).toFloat(),mInnerTextPaint!!)

canvas.drawText(mProgressF.toString(),(centerX + mProgressF.div(100) * mRadarBorderWidth * cons).toFloat(),
                 (centerY - mProgressF.div(100)*mRadarBorderWidth.div(2)).toFloat(),mInnerTextPaint!!)

具體的動畫程式碼如下

 /*
* 開啟動畫
* */
private fun startAnim(){
    val animatorA = ValueAnimator.ofFloat(0f, mProgressA)
    animatorA.addUpdateListener {
        mCurrentA = it.animatedValue as Float
        invalidate()
    }
    animatorA.addListener(object : Animator.AnimatorListener{
        override fun onAnimationRepeat(animation: Animator?) {

        }

        override fun onAnimationEnd(animation: Animator?) {
            mTimeToShowInnerText = true
            invalidate()
        }

        override fun onAnimationCancel(animation: Animator?) {
        }

        override fun onAnimationStart(animation: Animator?) {
        }


    })
    animatorA.duration = mAnimDuration.toLong()
    animatorA.start()

    val animatorB = ValueAnimator.ofFloat(0f, mProgressB)
    animatorB.addUpdateListener {
        mCurrentB = it.animatedValue as Float
        invalidate()
    }
    animatorB.duration = mAnimDuration.toLong()
    animatorB.start()

    val animatorC = ValueAnimator.ofFloat(0f, mProgressC)
    animatorC.addUpdateListener {
        mCurrentC = it.animatedValue as Float
        invalidate()
    }
    animatorC.duration = mAnimDuration.toLong()
    animatorC.start()

    val animatorD = ValueAnimator.ofFloat(0f, mProgressD)
    animatorD.addUpdateListener {
        mCurrentD = it.animatedValue as Float
        invalidate()
    }
    animatorD.duration = mAnimDuration.toLong()
    animatorD.start()

    val animatorE = ValueAnimator.ofFloat(0f, mProgressE)
    animatorE.addUpdateListener {
        mCurrentE = it.animatedValue as Float
        invalidate()
    }
    animatorE.duration = mAnimDuration.toLong()
    animatorE.start()

    val animatorF = ValueAnimator.ofFloat(0f, mProgressF)
    animatorF.addUpdateListener {
        mCurrentF = it.animatedValue as Float
        invalidate()
    }
    animatorF.duration = mAnimDuration.toLong()
    animatorF.start()
}

4.自定義屬性和方法

app:radarBorderWidth="100dp"//雷達圖的邊長
app:radarAnimDuration="2000"//動畫的時長
app:radarBorderColor="@color/colorRed"//雷達圖背景色
app:radarOuterTextColor="@color/colorPrimary"//雷達圖外面的文字顏色
app:radarOuterTextSize="16sp"//雷達圖外面的文字大小
app:radarOuterTextMargin="10dp"//雷達圖外面的文字與雷達圖的間距
app:radarInnerTextColor="@color/black"//雷達圖內部顯示的文字顏色
app:radarInnerTextSize="8sp"//雷達圖內部顯示的文字大小
app:radarShowInnerText="true"//是否顯示內部的進度文字
app:radarProgressColor="@color/colorYellow"//雷達圖顯示進度區域的顏色
app:radarProgressAlpha="80"//雷達圖進度區域顏色的Alpha值[0-255]
app:radarBackgroundColor="@color/colorWhite"//雷達圖背景的顏色
app:radarBackgroundAlpha="255"//雷達圖背景顏色的Alpha值[0-255]
//雷達圖設定外部的六個文字
radarView.setRadarStrings("語文","數學","化學","物理","英語","生物")
//雷達圖設定六個成績
radarView.setRadarProgress(50.5f,60.5f,70f,80.5f,90f,45f)

最後,附上完整程式碼自定義View集合中的RadarView,如果能順手點個star也是極好的,麼麼噠