内容简介:支持支持
开箱即用的源码地址
支持 XML
自定义属性:
-
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:heavy_division_sign:rv_animateTime
-
支持 代码
设置数据源:
-
setTextArray(textList: List<String>)
:设置各属性文字数组, 元素个数不能小于3 -
setProgressList(progressList: List<Int>)
:设置各属性对应的进度数组,该数组元素默认都是0, 元素个数必须与文字数组保持一致 -
setOldProgressList(oldProgressList: List<Int>)
:设置各属性执行动画前,对应的进度数组,该数组元素默认都是0, 元素个数必须与文字数组保持一致
支持 代码
执行动画:
doInvalidate() doInvalidate(index: Int, block: ((Int) -> Unit)? = null)
起源
近来我司产品侧在重构一项业务,连带UI也有变动,其中就涉及到了 雷达图 ,所以也就有了这次封装的 RadarView
,其主要特点是:
- 有丰富的自定义属性,可对雷达图外观进行设置
- 支持自由设置属性个数
- 支持两种动画模式(时间一定、速度一定)
- 支持指定某属性执行动画(从而满足UI稿的个性需求,见头图三)
头图三联是演示了该View的主要特点,然后结合局部UI稿,大家可以对比看下(还原度99%,✧(≖ ◡ ≖✿)嘿嘿嘿)。
思考分析
NOTE:
- 我们把六角形抽象成N角形,后文统一使用 N角形 表示
- 我们在绘制前会把坐标系原点移动到雷达图中心,后文统一使用 原点 表示
我们先来思考下关键技术点:
- 绘制N角形雷达网
- 绘制虚线
- 虚线可以给
Paint
设置DashPathEffect
实现 - 以雷达图中心为原点,将坐标系每逆时针旋转
360/N
度,从原点向上绘制长度为雷达网半径
的虚线
- 虚线可以给
- 绘制实线
- 在绘制完
一条虚线
后,紧接着绘制实线 - 同样以雷达图中心为原点,将坐标系每向上移动
雷达网半径/4
后,并且顺时针旋转360/N/2
度(为什么是这个值?大家可自行 下),此时的坐标系x轴
刚好与对应的实线
重合 - 所以,直接从
移动旋转后的新坐标系原点
沿x轴
绘制实线即可 - 实线长度通过
雷达网半径
和相应角度的三角函数等可以算出来
- 在绘制完
- 绘制虚线
- 绘制N个角的属性文字
- 以雷达图中心为原点,以12点方向的角为第一个角,可以知道它的顶点坐标
(0,-半径)
- 通过圆上一点绕圆心(坐标原点)顺时针旋转
α弧度
得出另一点坐标的坐标公式
,可以算出各角顶点的坐标(后面会推导该公式!) - 再结合文字与顶点的间距、
Paint的setTextAlign()
、以及微调绘制文字时的Y坐标
就可以搞定啦
- 以雷达图中心为原点,以12点方向的角为第一个角,可以知道它的顶点坐标
- 绘制中心连接区域
(0,-半径✖进度️) 坐标公式 Path
- 绘制中心数字&名字
- 就是很基本的绘制文字,但需要注意微调
Y坐标
,以提高与UI稿的还原度(细节!细节!细节!)
- 就是很基本的绘制文字,但需要注意微调
- 添加动画效果
动画的本质就是不断调整各属性值的坐标,然后重绘View ValueAnimator
整理下思路框架:
- 定义、初始化属性
- 自定义属性部分
- 计算属性部分
- 绘制依赖的属性部分
- 定义设置属性的API
- 绘制N角形雷达网
- 绘制N个角的属性文字
- 绘制中心连接区域
- 绘制中心数字&名字
- 添加动画效果
技术点、思路理好了,按道理就要着手开始代码了,不过我们先上道数学题热热身。
热身数学题
请听题:根据9年义务教育所学,推导出 通过圆上一点绕圆心(坐标原点)顺时针旋转 α弧度
得出另一点坐标的 坐标公式
设圆半径为 r
,圆上有一点坐标A( , ),绕圆心顺时针旋转 α弧度
后得到坐标B( , ),有公式如下:
= -
= +
推导过程如下:
当一条射线从x轴的正方向(向右)开始逆时针方向旋转之后到了一个新位置(按顺序分别到达第一、二、三、四象限)得一个角.为了方便规定这个角是正角,所以逆时针方向也就相应规定为正方向了.
所以为了我们在代码中以顺时针为正方向,我们将 作为 带入上述公式
封装公式
现在我们就把上面的公式封装成 工具 代码,这可谓是一『利器』,日后我们自定义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() } 复制代码
绘制过程
1. 定义、初始化属性
1. 自定义属性部分
这一步比较容易,在 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 复制代码
2. 计算属性部分
所谓计算属性就是我们要 通过某自定义属性为基础计算得来的属性
。举个:chestnut::各属性描述文字的字体大小,此处我们使用 雷达图半径✖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 } 复制代码
3. 绘制依赖的属性部分
绘制依赖的属性就是我们在实际绘制时需要使用的 全局属性
,我们会提前初始化他们,这样就可以复用,避免在 draw()
方法中每次都 new对象
开辟内存。
假如我们在 draw()
方法中 new了Paint对象
,AS也会提示警告我们,截图和翻译如下:warning::
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) } } 复制代码
4. 定义设置属性的API
这里我们分别暴露设置文字数组、属性进度数组、执行动画前的进度数组三个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() } 复制代码
2. 绘制N角形雷达网
我们在绘制前整体将坐标系原点移动到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() } 复制代码
3. 绘制N个角的属性文字
这一步除了用代码实现我们先前的思路外,也有关于文字位置的整体处理与微调处理,这样一顿猛如虎的操作之后我们的还原度才能更上一层楼。
/** * 绘制文字数组 */ 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() } 复制代码
4. 绘制中心连接区域
这一步也算是驾轻就熟的操作了,根据各属性进度以及 坐标公式
,得出各点坐标,然后构建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() } 复制代码
5. 绘制中心数字&名字
这一步也是常规操作,唯一需注意的也是对文字位置的微调, 提高还原度
/** * 绘制中心文字 */ 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() } 复制代码
6. 添加动画效果
动画的本质就是不断调整各属性值的坐标,然后重绘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) } } } 复制代码
以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网
猜你喜欢:- koa 洋葱模型
- 区块链安全性的洋葱模型
- 主机安全:洋葱 Webshell 检测实践与思考
- koa2洋葱中学习async
- 如何更好地理解中间件和洋葱模型
- KOA2 compose 串联中间件实现(洋葱模型)
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。