之前在项目中需要用到雷达图,我就在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点 | 3–√2 3 2 / 2 * X | 0 |
B点 | 0 | 1/2 * X |
C点 | 0 | 3/2 * X |
D点 | 3–√2 3 2 / 2 * X | 2 * X |
E点 | 3–√2 3 2 * X | 3–√2 3 2 / 2 * X |
F点 | 3–√2 3 2 * 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点坐标(
3–√2
3
2
/ 2 * X,0)
A1的横坐标和A相同,纵坐标比A大了1/5 * X,所以A1的坐标就是(
3–√2
3
2
/2 * X,1/5 * X)
再来看B如何变化到B1:
B1横坐标 = B横坐标 + 1/5 * X *
3–√2
3
2
/ 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也是极好的,么么哒