文章目录
前言
本文是对 完全自定义View
的一次实践。实现了一个 扇形圆环. 包括渐变色,增长动画等.
好了话不多少, 我们先上图
一、分析
两个画笔, 一个背景, 一个前景; 然后绘制环形即可;
属性动画, 不断增加彩色环的角度; 然后不断重绘;
涉及问题点:
自定义属性接收, 包括色彩集合
onMeasure 适配View自适应的情况
色彩环 在 0° 位置 色彩分界线处理
二、上代码
1.自定义View代码
class FanRingView(context: Context, attrs: AttributeSet?)
: View(context, attrs) {
private val mPaint: Paint // 圆环前景画笔
private val mBgPaint: Paint // 圆环背景画笔
private var mRectF: RectF? = null // 圆环的矩形区域
private var mViewCenterX = 0f // view宽的中心点(圆心X)
private var mViewCenterY = 0f // view高的中心点(圆心Y)
private var mCurrentRing = 0f // 彩色区域值(随数值变化)
private var mSweepAngle = 270f // 圆环角度
private var mRingWidth = 18f // 圆环宽度
private var duration = 1500L // 动画时长
private var valueAnimator: ValueAnimator? = null // 属性动画
private lateinit var color: IntArray //渐变颜色
private val defaultWidth: Int // 自适应的默认宽高, 200dp
companion object{
const val MAX_VALUE = 100 // 圆环最大值
const val TAG = "FanRingView"
}
init {
val typedArray = context.obtainStyledAttributes(attrs, R.styleable.CircleProgressBar)
mSweepAngle = typedArray.getFloat(R.styleable.CircleProgressBar_sweepAngle, 270f)
val bgArcColor = typedArray.getColor(R.styleable.CircleProgressBar_bgArcColor, Color.LTGRAY)
mRingWidth = typedArray.getDimension(R.styleable.CircleProgressBar_arcWidth, 18f)
duration = typedArray.getInt(R.styleable.CircleProgressBar_animTime, 1500).toLong()
// 着色器 渐变色彩集合
val gradientArcColors = typedArray.getResourceId(R.styleable.CircleProgressBar_arcColors, 0)
Log.i(TAG, "initAttrs: gradientArcColors--$gradientArcColors")
if (gradientArcColors != 0) {
try {
val gradientColors = resources.getIntArray(gradientArcColors)
Log.i(TAG, "initAttrs: gradientColors.length::"+gradientColors.size)
when(gradientColors.size) {
0 -> {
//如果渐变色为数组为0,则尝试以单色读取色值
val colorSingle = resources.getColor(gradientArcColors,null)
color = IntArray(1)
color[0] = colorSingle
}
1 -> {
//如果渐变数组只有一种颜色,默认设为两种相同颜色
color = IntArray(1)
color[0] = gradientColors[0]
}
else -> {
color = gradientColors
}
}
} catch (e: Resources.NotFoundException) {
// 或给默认值
throw Resources.NotFoundException("the give resource not found.")
}
}
typedArray.recycle()
// 画笔的初始化可以滞后, 以减少 onCreate 的时耗. 这里博主懒了
//背景画笔
mBgPaint = Paint(Paint.ANTI_ALIAS_FLAG) // 抗锯齿
mBgPaint.style = Paint.Style.STROKE // 只绘制圆形的边
mBgPaint.strokeWidth = mRingWidth // 绘制宽度
mBgPaint.color = bgArcColor // 绘制颜色
mBgPaint.strokeCap = Paint.Cap.ROUND // 画笔笔刷类型, 末端圆角
//圆环画笔
mPaint = Paint(Paint.ANTI_ALIAS_FLAG)
mPaint.style = Paint.Style.STROKE
mPaint.strokeWidth = mRingWidth
mPaint.strokeCap = Paint.Cap.ROUND
defaultWidth = AndroidUtils.dp2px(context, 200f)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
// 适配自适应宽高, wrap_content 的情况
val widthSpecMode = MeasureSpec.getMode(widthMeasureSpec)
val widthSpecSize = MeasureSpec.getSize(widthMeasureSpec)
val heightSpecMode = MeasureSpec.getMode(heightMeasureSpec)
val heithtSpecSize = MeasureSpec.getSize(heightMeasureSpec)
when{
widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST -> {
// 宽高都自适应, 此时给固定宽高
setMeasuredDimension(defaultWidth, defaultWidth)
}
widthSpecMode == MeasureSpec.AT_MOST -> {
// 宽自适应, 高固定值; 那就按固定值设定宽高
setMeasuredDimension(heithtSpecSize, heithtSpecSize)
}
heightSpecMode == MeasureSpec.AT_MOST -> {
// 高自适应, 高固定值; 那就按固定值设定宽高
setMeasuredDimension(widthSpecSize, widthSpecSize)
}
else -> super.onMeasure(widthMeasureSpec, heightMeasureSpec)
}
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
// 尺寸变更时, 需重新确定圆心位置, 及 矩形区域
resetBlock(w, h)
}
private fun resetBlock(width: Int, height: Int){
if (width > 0 && height > 0) {
mViewCenterX = width / 2f
mViewCenterY = height / 2f
// 设置着色器
mPaint.shader = SweepGradient(mViewCenterX, mViewCenterX, color, null)
val radius = (width.coerceAtMost(height) - mRingWidth) / 2f
mRectF = RectF(mViewCenterX - radius, mViewCenterY - radius, mViewCenterX + radius, mViewCenterY + radius)
}
}
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
if(mRectF == null){
// 确保, 圆心点 及 Rect初始化;
resetBlock(measuredWidth, measuredHeight)
}
// canvas.drawArc 为逆时针绘制
// 其中 startAngle: right=0; bottom=90; left=180; top=270;
val start = 90f + (360f - mSweepAngle) / 2f
//画背景圆环
drawNormalRing(canvas, start)
//画彩色圆环
drawColorRing(canvas, start)
}
/**
* 画背景圆环
*/
private fun drawNormalRing(canvas: Canvas?, start: Float) {
// 由于背景环 与 彩色环 存在重叠. 存在过度绘制. 我们尽可能减少过度绘制区域
// start, mSweepAngle, mCurrentRing 都为 角度,度数 参数
val startReal = start + mCurrentRing - 5f
val ringReal = mSweepAngle - mCurrentRing + 5f
canvas?.drawArc(mRectF!!, startReal, ringReal, false, mBgPaint)
}
/**
* 画彩色圆环
*/
private fun drawColorRing(canvas: Canvas?, start: Float) {
if(mCurrentRing == 0f) return
// 画布旋转; 防止 着色器 在0° 卡出颜色分界线;
canvas?.rotate(start - 5f, mViewCenterX, mViewCenterY)
canvas?.drawArc(mRectF!!, 5f, mCurrentRing, false, mPaint)
}
/**
* 设置当前值
*/
fun setValue(value: Int, textView: TextView) {
valueAnimator?.cancel() // 首先取消还未完成的 属性动画
val current = if (value > MAX_VALUE) MAX_VALUE else value
startAnimator(current, textView)
}
private fun startAnimator(end: Int, textView: TextView) {
valueAnimator = ValueAnimator.ofFloat(0f, end.toFloat()).also {
it.duration = duration
it.addUpdateListener { animation ->
Log.i(TAG, "startAnimator: AnimatedValue()--${animation.animatedValue}")
val i: Float = animation.animatedValue as Float
textView.text = i.toInt().toString()
// 计算度数
mCurrentRing = mSweepAngle / 100f * i
Log.i(TAG, "startAnimator: mSelectRing::$mCurrentRing")
invalidate()
// 动画完毕, 对象处理
if(i >= end) valueAnimator = null
}
it.start()
}
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
valueAnimator?.cancel()
valueAnimator = null
}
}
2.布局及Activity的代码
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".test.customview.FanRingActivity">
<com.example.kotlinmvpframe.test.customview.custom.FanRingView
android:id="@+id/frv_ring"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:arcWidth="10dp"
app:arcColors="@array/gradient_arc_color"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/tv_value"
style="@style/tv_base_16_dark"
android:layout_marginBottom="40dp"
android:padding="15dp"
android:background="?selectableItemBackground"
app:layout_constraintStart_toStartOf="@id/frv_ring"
app:layout_constraintEnd_toEndOf="@id/frv_ring"
app:layout_constraintBottom_toBottomOf="@id/frv_ring" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
Activity:
// 设置值
binding.frvRing.setValue(100, binding.tvValue)
// 点击TextView时, 随机设置值
binding.tvValue.setOnClickListener {
val score: Int = (1..100).random()
binding.frvRing.setValue(score, binding.tvValue)
}
3.其他代码
自定义属性: values/attr.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--********************基本圆形进度条***************-->
<!-- 圆弧度数 -->
<attr name="sweepAngle" format="float" />
<!-- 设置动画时间 -->
<attr name="animTime" format="integer" />
<!-- 背景圆弧颜色 -->
<attr name="bgArcColor" format="color|reference" />
<!-- 圆弧宽度 -->
<attr name="arcWidth" format="dimension" />
<!-- 圆弧颜色, -->
<attr name="arcColors" format="color|reference" />
<declare-styleable name="CircleProgressBar">
<attr name="sweepAngle" />
<attr name="animTime" />
<!-- 圆弧宽度 -->
<attr name="arcWidth" />
<attr name="arcColors" />
<!-- 背景圆弧颜色 -->
<attr name="bgArcColor" />
</declare-styleable>
</resources>
颜色集合文件: values/array.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<integer-array name="gradient_arc_color">
<item>0xFFFF0000</item>
<item>0xFFFFAA00</item>
<item>0xFFFFFF00</item>
</integer-array>
</resources>
三、部分讲解
1.Paint 设置:
mBgPaint.strokeCap = Paint.Cap.ROUND // 画笔笔刷类型, 末端圆角
上面这句是让圆环 末端圆角的关键代码. 不然末端为直线样式, 并不好看;
// 设置着色器
mPaint.shader = SweepGradient(mViewCenterX, mViewCenterX, color, null)
上面这句是设置 着色器. 颜色渐变的关键代码;
2. 零度位置, 色彩分界线处理
由于我们的圆环末端为圆角. 而着色器在 0°位置会产生一个分界线.
假如我们的代码类似这样:
canvas?.drawArc(mRectF!!, -90f, 350f, false, mPaint)
效果如下图所示:
即便我们旋转画布. 那么这个分界线也会在左下角产生.
因此: 我们将旋转画布, 并预留出5°的位置, 来避开这个分界线!
/**
* 画彩色圆环
*/
private fun drawColorRing(canvas: Canvas?, start: Float) {
if(mCurrentRing == 0f) return
// 画布旋转; 防止 着色器 在0° 卡出颜色分界线;
canvas?.rotate(start - 5f, mViewCenterX, mViewCenterY)
canvas?.drawArc(mRectF!!, 5f, mCurrentRing, false, mPaint)
}
3.View自适应
defaultWidth
该属性作为 默认的宽高值(目前为200dp)
在 onMeasure
回调中, 当宽高都为 wrap_content
时, 启用该属性;
4.属性动画:
需要注意的点:
当View销毁时, 关闭属性动画, 以免内存泄漏
再次设定数值时, 首先关掉未执行完毕的先前动画
动画执行完毕时, 对象置为 null. 以免内存泄漏
5.减少过度绘制
private fun drawNormalRing(canvas: Canvas?, start: Float) {
// 由于背景环 与 彩色环 存在重叠. 存在过度绘制. 我们尽可能减少过度绘制区域
val startReal = start + mCurrentRing - 5f
val ringReal = mSweepAngle - mCurrentRing + 5f
canvas?.drawArc(mRectF!!, startReal, ringReal, false, mBgPaint)
}
色彩环和背景环 会有重叠部分. 重叠部分背景的绘制 并无必要. 因此我们计算色彩环角度, 绘制背景环时 适当减去 色彩环的部分
总结
没有总结
上一篇: WorkManager笔记: 三、加急工作
下一篇: 记一次自定义View:滑动标尺