支持XML
自定義屬性:java
rv_webRadius
:雷達網的半徑(該屬性決定了View的寬高)rv_webMaxProgress
:各屬性表示的最大進度rv_webLineColor
:雷達網的顏色rv_webLineWidth
:雷達網的線寬rv_textArrayedColor
:各屬性文字的顏色rv_textArrayedFontPath
:各屬性文字和中心處名字的字體路徑rv_areaColor
:中心鏈接區域的顏色rv_areaBorderColor
:中心鏈接區域的邊框顏色rv_textCenteredName
:中心處的名字rv_textCenteredColor
:中心文字的顏色rv_textCenteredFontPath
:中心數字文字的字體路徑rv_animateTime
:動畫執行時間rv_animateMode
:動畫模式
TIME
:時間必定,動畫執行時間爲rv_animateTime
SPEED
:速度必定,動畫執行速度爲rv_webMaxProgress➗rv_animateTime
支持代碼
設置數據源:git
setTextArray(textList: List<String>)
:設置各屬性文字數組,元素個數不能小於3setProgressList(progressList: List<Int>)
:設置各屬性對應的進度數組,該數組元素默認都是0,元素個數必須與文字數組保持一致setOldProgressList(oldProgressList: List<Int>)
:設置各屬性執行動畫前,對應的進度數組,該數組元素默認都是0,元素個數必須與文字數組保持一致支持代碼
執行動畫:github
doInvalidate()
:各個屬性的動畫一塊兒執行doInvalidate(index: Int, block: ((Int) -> Unit)? = null)
:指定某屬性執行動畫,可傳入參數接收動畫結束的回調近來我司產品側在重構一項業務,連帶UI也有變更,其中就涉及到了雷達圖,因此也就有了此次封裝的RadarView
,其主要特色是:web
頭圖三聯是演示了該View的主要特色,而後結合局部UI稿,你們能夠對比看下(還原度99%,✧(≖ ◡ ≖✿)嘿嘿嘿)。canvas
NOTE:數組
咱們先來思考下關鍵技術點:app
Paint
設置DashPathEffect
實現360/N
度,從原點向上繪製長度爲雷達網半徑
的虛線一條虛線
後,緊接着繪製實線雷達網半徑/4
後,而且順時針旋轉360/N/2
度(爲何是這個值?你們可自行🤔下),此時的座標系x軸
恰好與對應的實線
重合移動旋轉後的新座標系原點
沿x軸
繪製實線便可雷達網半徑
和相應角度的三角函數等能夠算出來(0,-半徑)
α弧度
得出另外一點座標的座標公式
,能夠算出各角頂點的座標(後面會推導該公式!)Paint的setTextAlign()
、以及微調繪製文字時的Y座標
就能夠搞定啦(0,-半徑✖進度️)
座標公式
能夠算出各屬性的進度值座標Path
鏈接各點構建出路徑,而後繪製與描邊就很好說啦Y座標
,以提升與UI稿的還原度(細節!細節!細節!)動畫的本質就是不斷調整各屬性值的座標,而後重繪View
ValueAnimator
能夠搞定,也很簡單整理下思路框架:框架
技術點、思路理好了,按道理就要着手開始代碼了,不過咱們先上道數學題熱熱身。less
請聽題:根據9年義務教育所學,推導出經過圓上一點繞圓心(座標原點)順時針旋轉α弧度
得出另外一點座標的座標公式
設圓半徑爲r
,圓上有一點座標A(,
),繞圓心順時針旋轉
α弧度
後獲得座標B(,
),有公式以下:
=
-
=
+
推導過程以下:
當一條射線從x軸的正方向(向右)開始逆時針方向旋轉以後到了一個新位置(按順序分別到達第1、2、3、四象限)得一個角.爲了方便規定這個角是正角,因此逆時針方向也就相應規定爲正方向了.
因此爲了咱們在代碼中以順時針爲正方向,咱們將做爲
帶入上述公式
如今咱們就把上面的公式封裝成工具代碼,這可謂是一『利器』,往後咱們自定義View中也會常常用到!
首先,咱們要把360°的角度制(degree)
轉化爲弧度制(radian)
,這樣咱們在繪製時直接使用角度制會方便不少。
/** * 角度制轉弧度制 */
private fun Float.degree2radian(): Float {
return (this / 180f * PI).toFloat()
}
/** * 計算某角度的sin值 */
fun Float.degreeSin(): Float {
return sin(this.degree2radian())
}
/** * 計算某角度的cos值 */
fun Float.degreeCos(): Float {
return cos(this.degree2radian())
}
複製代碼
而後,根據公式寫代碼便可獲得咱們的『利器』。這裏咱們須要外部傳入PointF實例,而不是每次建立,以提高性能
/** * 計算一個點座標,繞原點旋轉必定角度後的座標 */
fun PointF.degreePointF(outPointF: PointF, degree: Float) {
outPointF.x = this.x * degree.degreeCos() - this.y * degree.degreeSin()
outPointF.y = this.y * degree.degreeCos() + this.x * degree.degreeSin()
}
複製代碼
這一步比較容易,在attrs.xml
中定義咱們的屬性,在Layout中聲明變量,並作初始化便可。這裏咱們就只貼出聲明變量的代碼。
//********************************
//* 自定義屬性部分
//********************************
/** * 雷達網圖半徑 */
private var mWebRadius: Float = 0f
/** * 雷達網圖半徑對應的最大進度 */
private var mWebMaxProgress: Int = 0
/** * 雷達網線顏色 */
@ColorInt
private var mWebLineColor: Int = 0
/** * 雷達網線寬度 */
private var mWebLineWidth: Float = 0f
/** * 雷達圖各定點文字顏色 */
@ColorInt
private var mTextArrayedColor: Int = 0
/** * 雷達圖文字數組字體路徑 */
private var mTextArrayedFontPath: String? = null
/** * 雷達圖中心鏈接區域顏色 */
@ColorInt
private var mAreaColor: Int = 0
/** * 雷達圖中心鏈接區域邊框顏色 */
@ColorInt
private var mAreaBorderColor: Int = 0
/** * 雷達圖中心文字名稱 */
private var mTextCenteredName: String = default_textCenteredName
/** * 雷達圖中心文字顏色 */
@ColorInt
private var mTextCenteredColor: Int = 0
/** * 雷達圖中心文字字體路徑 */
private var mTextCenteredFontPath: String? = null
/** * 文字數組,且以該數組長度肯定雷達圖是幾邊形 */
private var mTextArray: Array<String> by Delegates.notNull()
/** * 進度數組,與TextArray一一對應 */
private var mProgressArray: Array<Int> by Delegates.notNull()
/** * 執行動畫前的進度數組,與TextArray一一對應 */
private var mOldProgressArray: Array<Int> by Delegates.notNull()
/** * 動畫時間,爲0表明沒有動畫 * NOTE: 若是是速度必定模式下,表明從雷達中心執行動畫到頂點的時間 */
private var mAnimateTime: Long = 0L
/** * 動畫模式,默認爲時間必定模式 */
private var mAnimateMode: Int = default_animateMode
複製代碼
所謂計算屬性就是咱們要經過某自定義屬性爲基礎計算得來的屬性
。舉個🌰:各屬性描述文字的字體大小,此處咱們使用雷達圖半徑✖UI稿的比例
得來,其它屬性也同理,代碼以下:
//********************************
//* 計算屬性部分
//********************************
/** * 垂直文本距離雷達主圖的寬度 */
private var mVerticalSpaceWidth: Float by Delegates.notNull()
/** * 水平文本距離雷達主圖的寬度 */
private var mHorizontalSpaceWidth: Float by Delegates.notNull()
/** * 文字數組中的字體大小 */
private var mTextArrayedSize: Float by Delegates.notNull()
/** * 文字數組設置字體大小後的文字寬度,取字數最多的 */
private var mTextArrayedWidth: Float by Delegates.notNull()
/** * 文字數組設置字體大小後的文字高度 */
private var mTextArrayedHeight: Float by Delegates.notNull()
/** * 該View的寬度 */
private var mWidth: Float by Delegates.notNull()
/** * 該View的高度 */
private var mHeight: Float by Delegates.notNull()
複製代碼
/** * 初始化計算屬性,基本的寬高、字體大小、間距等數據 * NOTE:以UI稿比例爲準,根據[mWebRadius]來計算 */
private fun initCalculateAttributes() {
//根據比例計算相應屬性
(mWebRadius / 100).let {
mVerticalSpaceWidth = it * 8
mHorizontalSpaceWidth = it * 10
mTextArrayedSize = it * 12
}
//設置字體大小後,計算文字所佔寬高
mPaint.textSize = mTextArrayedSize
mTextArray.maxBy { it.length }?.apply {
mTextArrayedWidth = mPaint.measureText(this)
mTextArrayedHeight = mPaint.fontSpacing
}
mPaint.utilReset()
//動態計算出view的實際寬高
mWidth = (mTextArrayedWidth + mHorizontalSpaceWidth + mWebRadius) * 2.1f
mHeight = (mTextArrayedHeight + mVerticalSpaceWidth + mWebRadius) * 2.1f
}
複製代碼
繪製依賴的屬性就是咱們在實際繪製時須要使用的全局屬性
,咱們會提早初始化他們,這樣就能夠複用,避免在draw()
方法中每次都new對象
開闢內存。
假如咱們在draw()
方法中new了Paint對象
,AS也會提示警告咱們,截圖和翻譯以下⚠️:
Avoid object allocations during draw/layout operations (preallocate and reuse instead) less... (⌘F1)
避免在繪製和佈局期間建立對象,採用提早建立和能複用的方式代替
Inspection info:You should avoid allocating objects during a drawing or layout operation.
These are called frequently, so a smooth UI can be interrupted by garbage collection pauses caused by the object allocations.
你應該避免在繪製和佈局期間建立對象。他們會被頻繁執行,所以,平滑的UI會被對象分配致使的垃圾收集暫停中斷。
The way this is generally handled is to allocate the needed objects up front and to reuse them for each drawing operation.
通常的處理方式就是提早初始化須要的對象並在每次繪製操做時複用它們。
Some methods allocate memory on your behalf (such as Bitmap.create), and these should be handled in the same way.
有些方法替你分配了內存(好比Bitmap.create),這些方法應該採用相同的處理方式。
Issue id: DrawAllocation
複製代碼
因此咱們把畫筆、Path、存放座標的數組等全局聲明並初始化,代碼以下:
//********************************
//* 繪製使用的屬性部分
//********************************
/** * 全局畫筆 */
private val mPaint = createPaint()
private val mHelperPaint = createPaint()
/** * 全局路徑 */
private val mPath = Path()
/** * 雷達網虛線效果 */
private var mDashPathEffect: DashPathEffect by Delegates.notNull()
/** * 雷達主圖各頂點的座標數組 */
private var mPointArray: Array<PointF> by Delegates.notNull()
/** * 文字數組各文字的座標數組 */
private var mTextArrayedPointArray: Array<PointF> by Delegates.notNull()
/** * 文字數組各進度的座標數組 */
private var mProgressPointArray: Array<PointF> by Delegates.notNull()
/** * 做轉換使用的臨時變量 */
private var mTempPointF: PointF = PointF()
/** * 雷達圖文字數組字體 */
private var mTextArrayedTypeface: Typeface? = null
/** * 雷達圖中心文字字體 */
private var mTextCenteredTypeface: Typeface? = null
/** * 動畫處理器數組 */
private var mAnimatorArray: Array<ValueAnimator?> by Delegates.notNull()
/** * 各雷達屬性動畫的時間數組 */
private var mAnimatorTimeArray: Array<Long> by Delegates.notNull()
複製代碼
/** * 初始化繪製相關的屬性 */
private fun initDrawAttributes() {
context.dpf2pxf(2f).run {
mDashPathEffect = DashPathEffect(floatArrayOf(this, this), this)
}
mPointArray = Array(mTextArray.size) { PointF(0f, 0f) }
mTextArrayedPointArray = Array(mTextArray.size) { PointF(0f, 0f) }
mProgressPointArray = Array(mTextArray.size) { PointF(0f, 0f) }
if (mTextArrayedFontPath != null) {
mTextArrayedTypeface = Typeface.createFromAsset(context.assets, mTextArrayedFontPath)
}
if (mTextCenteredFontPath != null) {
mTextCenteredTypeface = Typeface.createFromAsset(context.assets, mTextCenteredFontPath)
}
}
複製代碼
這裏咱們分別暴露設置文字數組、屬性進度數組、執行動畫前的進度數組三個API
//********************************
//* 設置數據屬性部分
//********************************
fun setTextArray(textList: List<String>) {
this.mTextArray = textList.toTypedArray()
this.mProgressArray = Array(mTextArray.size) { 0 }
this.mOldProgressArray = Array(mTextArray.size) { 0 }
initView()
}
fun setProgressList(progressList: List<Int>) {
this.mProgressArray = progressList.toTypedArray()
initView()
}
/** * 設置執行動畫前的進度 */
fun setOldProgressList(oldProgressList: List<Int>) {
this.mOldProgressArray = oldProgressList.toTypedArray()
initView()
}
複製代碼
咱們在繪製前總體將座標系原點移動到View的中心處,這樣很便於以後的繪製。以下:
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
if (canvas == null) return
canvas.helpGreenCurtain(debug)
canvas.save()
canvas.translate(mWidth / 2, mHeight / 2)
//此處作數據校驗
if (checkIllegalData(canvas)) {
//繪製網狀圖形
drawWeb(canvas)
//繪製文字數組
drawTextArray(canvas)
//繪製鏈接區域
drawConnectionArea(canvas)
//繪製中心的文字
drawCenterText(canvas)
}
canvas.restore()
}
複製代碼
而後就是繪製N角形網了,代碼就是咱們先前思路的具體體現。
/** * 繪製網狀圖形 */
private fun drawWeb(canvas: Canvas) {
canvas.save()
val rDeg = 360f / mTextArray.size
mTextArray.forEachIndexed { index, _ ->
//繪製虛線,每次都將座標系逆時針旋轉(rDeg * index)度
canvas.save()
canvas.rotate(-rDeg * index)
mPaint.pathEffect = mDashPathEffect
mPaint.color = mWebLineColor
mPaint.strokeWidth = mWebLineWidth
canvas.drawLine(0f, 0f, 0f, -mWebRadius, mPaint)
mPaint.utilReset()
//用三角函數計算出最長的網的邊
val lineW = mWebRadius * (rDeg / 2).degreeSin() * 2
for (i in 1..4) {
//繪製網的邊,每次將座標系向上移動(mWebRadius / 4f)*i,
//且順時針旋轉(rDeg / 2)度,而後繪製長度爲(lineW / 4f * i)的實線
canvas.save()
canvas.translate(0f, -mWebRadius / 4f * i)
canvas.rotate(rDeg / 2)
mPaint.color = mWebLineColor
mPaint.strokeWidth = mWebLineWidth
canvas.drawLine(0f, 0f, lineW / 4f * i, 0f, mPaint)
mPaint.utilReset()
canvas.restore()
}
canvas.restore()
}
canvas.restore()
}
複製代碼
這一步除了用代碼實現咱們先前的思路外,也有關於文字位置的總體處理與微調處理,這樣一頓猛如虎的操做以後咱們的還原度才能更上一層樓。
/** * 繪製文字數組 */
private fun drawTextArray(canvas: Canvas) {
canvas.save()
val rDeg = 360f / mTextArray.size
//先計算出雷達圖各個頂點的座標
mPointArray.forEachIndexed { index, pointF ->
if (index == 0) {
pointF.x = 0f
pointF.y = -mWebRadius
} else {
mPointArray[index - 1].degreePointF(pointF, rDeg)
}
//繪製輔助圓點
if (debug) {
mHelperPaint.color = Color.RED
canvas.drawCircle(pointF.x, pointF.y, 5f, mHelperPaint)
mHelperPaint.utilReset()
}
}
//基於各頂點座標,計算出文字座標並繪製文字
mTextArrayedPointArray.mapIndexed { index, pointF ->
pointF.x = mPointArray[index].x
pointF.y = mPointArray[index].y
return@mapIndexed pointF
}.forEachIndexed { index, pointF ->
mPaint.color = mTextArrayedColor
mPaint.textSize = mTextArrayedSize
if (mTextArrayedTypeface != null) {
mPaint.typeface = mTextArrayedTypeface
}
when {
index == 0 -> {
//微調修正文字y座標
pointF.y += mPaint.getBottomedY()
pointF.y = -(pointF.y.absoluteValue + mVerticalSpaceWidth)
mPaint.textAlign = Paint.Align.CENTER
}
mTextArray.size / 2f == index.toFloat() -> {
//微調修正文字y座標
pointF.y += mPaint.getToppedY()
pointF.y = (pointF.y.absoluteValue + mVerticalSpaceWidth)
mPaint.textAlign = Paint.Align.CENTER
}
index < mTextArray.size / 2f -> {
//微調修正文字y座標
if (pointF.y < 0) {
pointF.y += mPaint.getBottomedY()
} else {
pointF.y += mPaint.getToppedY()
}
pointF.x = (pointF.x.absoluteValue + mHorizontalSpaceWidth)
mPaint.textAlign = Paint.Align.LEFT
}
index > mTextArray.size / 2f -> {
//微調修正文字y座標
if (pointF.y < 0) {
pointF.y += mPaint.getBottomedY()
} else {
pointF.y += mPaint.getToppedY()
}
pointF.x = -(pointF.x.absoluteValue + mHorizontalSpaceWidth)
mPaint.textAlign = Paint.Align.RIGHT
}
}
canvas.drawText(mTextArray[index], pointF.x, pointF.y, mPaint)
mPaint.utilReset()
}
canvas.restore()
}
複製代碼
這一步也算是得心應手的操做了,根據各屬性進度以及座標公式
,得出各點座標,而後構建Path,繪製便可。
/** * 繪製雷達鏈接區域 */
private fun drawConnectionArea(canvas: Canvas) {
canvas.save()
val rDeg = 360f / mTextArray.size
//根據雷達圖第一個座標最爲基座標進行相應計算,算出各個進度座標
val bPoint = mPointArray.first()
mProgressPointArray.forEachIndexed { index, pointF ->
val progress = mProgressArray[index] / mWebMaxProgress.toFloat()
pointF.x = bPoint.x * progress
pointF.y = bPoint.y * progress
pointF.degreePointF(mTempPointF, rDeg * index)
pointF.x = mTempPointF.x
pointF.y = mTempPointF.y
//繪製輔助圓點
if (debug) {
mHelperPaint.color = Color.BLACK
canvas.drawCircle(pointF.x, pointF.y, 5f, mHelperPaint)
mHelperPaint.utilReset()
}
//使用路徑鏈接各個點
if (index == 0) {
mPath.moveTo(pointF.x, pointF.y)
} else {
mPath.lineTo(pointF.x, pointF.y)
}
if (index == mProgressPointArray.lastIndex) {
mPath.close()
}
}
//繪製區域路徑
mPaint.color = mAreaColor
canvas.drawPath(mPath, mPaint)
mPaint.utilReset()
//繪製區域路徑的邊框
mPaint.color = mAreaBorderColor
mPaint.style = Paint.Style.STROKE
mPaint.strokeWidth = mWebLineWidth
mPaint.strokeJoin = Paint.Join.ROUND
canvas.drawPath(mPath, mPaint)
mPath.reset()
mPaint.utilReset()
canvas.restore()
}
複製代碼
這一步也是常規操做,惟一需注意的也是對文字位置的微調,提升還原度
/** * 繪製中心文字 */
private fun drawCenterText(canvas: Canvas) {
canvas.save()
//繪製數字
mPaint.color = mTextCenteredColor
mPaint.textSize = mTextArrayedSize / 12 * 20
mPaint.textAlign = Paint.Align.CENTER
if (mTextCenteredTypeface != null) {
mPaint.typeface = mTextCenteredTypeface
}
//將座標系向下微調移動
canvas.translate(0f, mPaint.fontMetrics.bottom)
var sum = mProgressArray.sum().toString()
//添加輔助文本
if (debug) {
sum += "ajk你好"
}
canvas.drawText(sum, 0f, mPaint.getBottomedY(), mPaint)
mPaint.utilReset()
//繪製名字
mPaint.color = mTextCenteredColor
mPaint.textSize = mTextArrayedSize / 12 * 10
mPaint.textAlign = Paint.Align.CENTER
if (mTextArrayedTypeface != null) {
mPaint.typeface = mTextArrayedTypeface
}
canvas.drawText(mTextCenteredName, 0f, mPaint.getToppedY(), mPaint)
mPaint.utilReset()
//繪製輔助線
if (debug) {
mHelperPaint.color = Color.RED
mHelperPaint.strokeWidth = context.dpf2pxf(1f)
canvas.drawLine(-mWidth, 0f, mWidth, 0f, mHelperPaint)
mHelperPaint.utilReset()
}
canvas.restore()
}
複製代碼
動畫的本質就是不斷調整各屬性值的座標,而後重繪View
在咱們的代碼中各屬性的座標又是根據屬性進度得來的,因此不斷調整各屬性進度就能產生動畫。
在初始化操做時要提早初始化動畫處理器
/** * 初始化動畫處理器 */
private fun initAnimator() {
mAnimatorArray = Array(mTextArray.size) { null }
mAnimatorTimeArray = Array(mTextArray.size) { 0L }
mAnimatorArray.forEachIndexed { index, _ ->
val sv = mOldProgressArray[index].toFloat()
val ev = mProgressArray[index].toFloat()
mAnimatorArray[index] = if (sv == ev) null else ValueAnimator.ofFloat(sv, ev)
if (mAnimateMode == ANIMATE_MODE_TIME) {
mAnimatorTimeArray[index] = mAnimateTime
} else {
//根據最大進度和動畫時間算出恆定速度
val v = mWebMaxProgress.toFloat() / mAnimateTime
mAnimatorTimeArray[index] = if (sv == ev) 0L else ((ev - sv) / v).toLong()
}
}
}
複製代碼
/** * 各屬性動畫一塊兒執行 */
fun doInvalidate() {
mAnimatorArray.forEachIndexed { index, _ ->
doInvalidate(index)
}
}
/** * 指定某屬性開始動畫 */
fun doInvalidate(index: Int, block: ((Int) -> Unit)? = null) {
if (index >= 0 && index < mAnimatorArray.size) {
val valueAnimator = mAnimatorArray[index]
val at = mAnimatorTimeArray[index]
if (valueAnimator != null && at > 0) {
valueAnimator.duration = at
valueAnimator.removeAllUpdateListeners()
valueAnimator.addUpdateListener {
val av = (it.animatedValue as Float)
mProgressArray[index] = av.toInt()
invalidate()
}
//設置動畫結束監聽
if (block != null) {
valueAnimator.removeAllListeners()
valueAnimator.addListener(object : Animator.AnimatorListener {
override fun onAnimationRepeat(animation: Animator?) {}
override fun onAnimationEnd(animation: Animator?) {
block.invoke(index)
}
override fun onAnimationCancel(animation: Animator?) {}
override fun onAnimationStart(animation: Animator?) {}
})
}
valueAnimator.start()
} else {
block?.invoke(index)
}
}
}
複製代碼
我的能力有限,若有不正之處歡迎你們批評指出,我會虛心接受並第一時間修改,以不誤導你們。