支持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)
}
}
}
复制代码
我的能力有限,若有不正之处欢迎你们批评指出,我会虚心接受并第一时间修改,以不误导你们。